Compare commits

..

122 Commits

Author SHA1 Message Date
renovate[bot]
4169d88acb Update python Docker tag to v3.14 2025-10-08 06:57:12 +00:00
renovate[bot]
e0ecb56b14 Update astral-sh/setup-uv action to v7 (#201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 00:47:50 +00:00
069760fb5e Update Docker actions to latest versions in docker-publish.yml 2025-10-02 22:12:20 +02:00
renovate[bot]
b07d5a8dc5 Update astral-sh/setup-uv digest to 59a0868 (#199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 20:08:53 +00:00
3691a2d812 Add new words to cSpell dictionary in settings.json 2025-10-02 22:04:59 +02:00
7c169f4329 Update pre-commit hooks versions and remove unused imports in main.py 2025-10-02 22:04:50 +02:00
9c2a2e66c5 Remove uv.lock 2025-10-02 22:03:34 +02:00
renovate[bot]
0bb55726fe Update astral-sh/setup-uv digest to f2859da (#198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 18:46:47 +00:00
renovate[bot]
b678e7784a Update python:3.13-slim Docker digest to 5f55cdf (#197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 05:54:03 +00:00
renovate[bot]
90a9a17a63 Update python:3.13-slim Docker digest to 60df8d2 (#196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 01:28:05 +00:00
renovate[bot]
d16da0de6f Update python:3.13-slim Docker digest to a6196d2 (#195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-01 20:39:26 +00:00
renovate[bot]
d051b96069 Update astral-sh/setup-uv digest to 82f21a5 (#194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-01 01:45:02 +00:00
renovate[bot]
49dd097948 Update python:3.13-slim Docker digest to 3a6ead7 (#193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 21:42:53 +00:00
renovate[bot]
992e3ae3c4 Update astral-sh/setup-uv digest to d0cc045 (#192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 17:39:39 +00:00
renovate[bot]
ee94587a0b Update python:3.13-slim Docker digest to 6cbc435 (#191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 10:33:28 +00:00
renovate[bot]
03c76f1154 Update docker/login-action digest to 5e57cd1 (#190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 11:08:53 +00:00
renovate[bot]
cf11b1b5a5 Update astral-sh/setup-uv digest to 2841f9f (#189)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 10:49:15 +00:00
renovate[bot]
2341932af5 Update astral-sh/setup-uv digest to c7d85d9 (#188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-23 09:30:56 +00:00
renovate[bot]
02052d5e2a Update astral-sh/setup-uv digest to b75a909 (#187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-14 17:34:26 +00:00
renovate[bot]
ec17bc35d9 Update astral-sh/setup-uv digest to ffff8aa (#186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-14 13:04:07 +00:00
renovate[bot]
431c9c1a27 Update python:3.13-slim Docker digest to 58c30f5 (#185)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-14 02:39:00 +00:00
renovate[bot]
35c45fc230 Update docker/login-action digest to 5b7b28b (#184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 23:25:07 +00:00
renovate[bot]
17d2b6956b Update docker/setup-qemu-action digest to e77e806 (#183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-12 04:52:02 +00:00
renovate[bot]
0e91dcfb82 Update astral-sh/setup-uv digest to f67343a (#182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 09:26:52 +00:00
renovate[bot]
3d6680b4bd Update astral-sh/setup-uv digest to 4dd9f52 (#181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 06:13:33 +00:00
renovate[bot]
81c34a3446 Update python:3.13-slim Docker digest to 1bca020 (#180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 10:39:22 +00:00
renovate[bot]
8565243c4c Update python:3.13-slim Docker digest to 8ebd0ea (#179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 05:59:14 +00:00
renovate[bot]
8ecbce4d12 Update python:3.13-slim Docker digest to f71edd7 (#178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 01:54:13 +00:00
renovate[bot]
6208da86c0 Update docker/login-action digest to bdf14dc (#177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 17:41:52 +00:00
renovate[bot]
b8bdf824a6 Update astral-sh/setup-uv digest to e1e6fe7 (#176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 16:10:31 +00:00
renovate[bot]
cd10e20cab Update astral-sh/setup-uv digest to 26cf676 (#175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-31 10:40:57 +00:00
renovate[bot]
bfb2b0b81e Update astral-sh/setup-uv digest to 4e1e303 (#174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 11:44:18 +00:00
renovate[bot]
7f84da5b9c Update astral-sh/setup-uv digest to 4959332 (#172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 00:00:37 +00:00
renovate[bot]
79f16a44a3 Update docker/setup-buildx-action digest to 1583c0f (#173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-21 18:57:16 +00:00
renovate[bot]
18008be8f3 Update astral-sh/setup-uv digest to f758a4a (#171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 23:40:42 +00:00
renovate[bot]
962c369b88 Update python:3.13-slim Docker digest to 27f90d7 (#170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 18:31:07 +00:00
renovate[bot]
87ed08d250 Update docker/setup-buildx-action digest to 4cc794f (#169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 13:16:37 +00:00
renovate[bot]
37ddfd24c3 Update python:3.13-slim Docker digest to 4d55aff (#168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-16 04:36:35 +00:00
renovate[bot]
3c0638ba47 Update python:3.13-slim Docker digest to 4112a75 (#167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-16 00:49:59 +00:00
renovate[bot]
5dbac43b27 Update astral-sh/setup-uv digest to c0e7e93 (#165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 02:06:42 +00:00
renovate[bot]
3a268c0cb5 Update python:3.13-slim Docker digest to 2a928e1 (#166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 21:34:12 +00:00
renovate[bot]
d8d57a6581 Update astral-sh/setup-uv digest to fda2399 (#164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 10:32:58 +00:00
renovate[bot]
35b1874714 Update python:3.13-slim Docker digest to 201e1ab (#163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 21:30:58 +00:00
renovate[bot]
1fc3482c36 Update actions/checkout digest to ff7abcd (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 16:32:16 +00:00
renovate[bot]
dd7e51fc3f Update python:3.13-slim Docker digest to 6a135f8 (#161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 10:48:19 +00:00
renovate[bot]
ea4f3882f9 Update python:3.13-slim Docker digest to faa4eb6 (#160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 07:07:37 +00:00
renovate[bot]
c89faf85db Update astral-sh/setup-uv digest to d9e0f98 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 04:08:19 +00:00
renovate[bot]
364f1b8aea Update astral-sh/setup-uv digest to e5d42a2 (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-12 20:39:58 +00:00
renovate[bot]
ffaae102c5 Update astral-sh/setup-uv digest to 1463845 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-12 10:35:48 +00:00
renovate[bot]
3f489edb37 Update docker/setup-buildx-action digest to af1b253 (#156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 16:50:56 +00:00
renovate[bot]
ca29c06c81 Update actions/checkout digest to 08c6903 (#155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 13:53:33 +00:00
renovate[bot]
dd9dab3f8b Update astral-sh/setup-uv digest to ad5ded2 (#154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 09:13:19 +00:00
renovate[bot]
a0a7519f14 Update python:3.13-slim Docker digest to 6f79e7a (#153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 21:45:55 +00:00
renovate[bot]
4ef58a394b Update docker/setup-buildx-action digest to 2c8bcda (#152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 08:49:49 +00:00
renovate[bot]
d2ffd0c9c5 Update docker/setup-buildx-action digest to c65d441 (#151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 17:40:43 +00:00
renovate[bot]
e81db87cd4 Update astral-sh/setup-uv digest to 1422404 (#150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 12:47:16 +00:00
renovate[bot]
c11714fe8c Update docker/setup-buildx-action digest to ae7d689 (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 17:31:50 +00:00
renovate[bot]
d46b648134 Update docker/login-action digest to 184bdaa (#148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 16:26:58 +00:00
renovate[bot]
747b031eca Update docker/login-action digest to ef38ec3 (#147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 14:32:52 +00:00
renovate[bot]
ae0a83c1f0 Update astral-sh/setup-uv digest to 6324490 (#146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 01:31:56 +00:00
renovate[bot]
d799845ba7 Update astral-sh/setup-uv digest to 2a967c9 (#145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-31 08:55:33 +00:00
renovate[bot]
84ec0e673b Update astral-sh/setup-uv digest to 43f3736 (#144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 13:38:51 +00:00
renovate[bot]
963c8f4e3a Update astral-sh/setup-uv digest to 4fb0c07 (#143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-25 05:27:25 +00:00
renovate[bot]
28bf3679de Update python:3.13-slim Docker digest to 4c2cf99 (#142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 02:09:28 +00:00
renovate[bot]
cb8213aa74 Update astral-sh/setup-uv digest to e92bafb (#141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 23:07:25 +00:00
renovate[bot]
c5de3bfe71 Update actions/checkout digest to 8edcb1b (#140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 17:56:54 +00:00
renovate[bot]
7025fafb72 Update astral-sh/setup-uv digest to 23482a3 (#139)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 11:59:04 +00:00
renovate[bot]
453a44f125 Update python:3.13-slim Docker digest to 1020ca4 (#138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 21:10:35 +00:00
renovate[bot]
1cdbcf1d96 Update python:3.13-slim Docker digest to 9248661 (#137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 15:04:51 +00:00
renovate[bot]
e930a3bd9b Update python:3.13-slim Docker digest to 97fe872 (#136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 07:57:32 +00:00
renovate[bot]
3a340d622f Update python:3.13-slim Docker digest to ed8ae2e (#135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 03:33:13 +00:00
c665ea9ea9 Add Sentry log filtering to reduce noise in error reporting 2025-07-19 00:40:41 +02:00
c8d7e059b2 Refactor Sentry integration: move initialization to RemindBotClient and enhance error context logging 2025-07-18 23:03:49 +02:00
a99c381bec Enhance message sending reliability by adding a delay before sending and improving error logging for bot state issues 2025-07-18 21:37:14 +02:00
bb752cd6cc Merge branch 'master' of https://github.com/TheLovinator1/discord-reminder-bot 2025-07-18 21:24:10 +02:00
c3161c2e0a Update dependencies: aiohttp to 3.12.14, certifi to 2025.7.14, orjson to 3.11.0, and sentry-sdk to 2.33.0 2025-07-18 21:24:04 +02:00
ca3c3bbb1b Enhance error handling and logging in Discord message sending functions
- Added detailed bot state information to error messages when sending fails.
- Implemented early validation of bot readiness and closure in `send_to_discord`.
- Introduced `safe_send_to_discord` to manage bot state issues and prevent '_MissingSentinel' errors.
- Improved logging for better debugging of bot state and HTTP client status.
2025-07-18 21:23:35 +02:00
renovate[bot]
4f08d4b73f Update astral-sh/setup-uv digest to 4ac06a0 (#134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 13:15:51 +00:00
renovate[bot]
478138242d Update astral-sh/setup-uv digest to 05273c1 (#133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 23:33:42 +00:00
renovate[bot]
eaa231bf5a Update astral-sh/setup-uv digest to c893ac1 (#132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 10:38:59 +00:00
renovate[bot]
e1910d6cf8 Update astral-sh/setup-uv digest to a905f00 (#131)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 07:11:06 +00:00
4f5d62b30d Merge branch 'master' of https://github.com/TheLovinator1/discord-reminder-bot 2025-07-07 06:02:20 +02:00
renovate[bot]
6edc70d6e1 Update astral-sh/setup-uv digest to d4219d1 (#129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Joakim Hellsén <tlovinator@gmail.com>
2025-07-07 04:00:16 +00:00
renovate[bot]
6f9a2cc995 Update docker/setup-qemu-action digest to 05340d1 (#130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Joakim Hellsén <tlovinator@gmail.com>
2025-07-07 04:00:02 +00:00
7c39f5fdc8 Merge branch 'master' of https://github.com/TheLovinator1/discord-reminder-bot 2025-07-07 05:57:23 +02:00
8237b3ac46 Add WEBHOOK_URL to environment variables in docker-compose.yml 2025-07-07 05:57:18 +02:00
renovate[bot]
2ff50ba2cd Update actions/checkout digest to 09d2aca (#128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 03:55:54 +00:00
09cccd51cc Update SQLITE_LOCATION environment variable path in Dockerfile 2025-07-07 05:41:24 +02:00
c7c761a8cd Update Docker image reference to use GitHub Container Registry 2025-07-07 05:36:46 +02:00
e58dab8163 Refactor Dockerfile and docker-compose.yml to set environment variables directly and remove unused variables 2025-07-07 05:33:37 +02:00
1508147399 Go back to GitHub workflows 2025-07-07 05:14:13 +02:00
2f6f74c82f Activate .gitea workflow 2025-07-07 04:57:19 +02:00
59edb2a41f Merge pull request 'Rewrite bot' (!1) from kewlrewrite into master
Reviewed-on: #1
2025-07-07 04:50:47 +02:00
fe559c41a9 Enhance missed job notifications with markdown timestamp and improve webhook function signature 2025-07-07 04:46:30 +02:00
d7ea1c9ec4 Improve message sent to Discord for missed reminder 2025-07-06 06:02:05 +02:00
9f2814a3d5 Add message modification support for interval and cron jobs in modals 2025-07-06 04:06:44 +02:00
4dde52ec04 Refactor reminder modification modals to support date, cron, and interval triggers 2025-07-06 02:28:36 +02:00
c6d8df3d80 Add helper functions and modal for modifying APScheduler jobs 2025-07-06 00:38:39 +02:00
77575b0934 Implement modal for modifying APScheduler jobs 2025-07-05 23:28:15 +02:00
e5c662ba20 Add job id to logger.error when failing to format the __getstate__ dictionary for Discord markdown 2025-07-05 04:00:57 +02:00
b97e8260ae Debug with module instead of starting main.py 2025-07-05 03:51:25 +02:00
993a8fd6d9 Start scheduler after syncing commands 2025-07-04 23:39:26 +02:00
480b36ad85 Update Dockerfile to specify Python image digest and add support for build checks 2025-07-04 05:58:36 +02:00
679fedb099 Update Dockerfile to run the main module directly with Python 2025-07-04 02:03:25 +02:00
12f705418a Log timezone; add more data to /remind list 2025-07-03 06:04:24 +02:00
865cd9ba6d Refactor state generation functions for improved markdown formatting; simplify error messages and enhance UI job display 2025-07-02 23:40:32 +02:00
1cf10fc7a9 Update multidict package to version 6.6.3 2025-07-02 06:31:06 +02:00
9299ab800d Refactor reminder job handling and improve UI formatting; add pagination for reminders 2025-07-02 06:30:53 +02:00
acf742c91c Add Discord session ID to logs 2025-07-01 21:45:06 +02:00
fd826da6e4 Stuff and things 2025-07-01 20:10:29 +02:00
b87639910b Update installation instructions and bump Python requirement to 3.13; remove Poetry and nox; update pre-commit hooks. 2025-06-29 04:24:25 +02:00
41c03e10f6 Comment out push, pull_request, and schedule triggers in workflow files 2025-04-12 14:23:39 +02:00
5ec31ba126 Add Nox to development dependencies and format dependency entries 2025-04-12 11:35:50 +02:00
52d8501ef2 Remove SECURITY.md 2025-04-12 11:34:53 +02:00
12c5ece487 Add pause and unpause commands for reminders 2025-04-12 10:59:29 +02:00
7b192fc425 Ignore SQLite database backup files 2025-04-11 21:32:32 +02:00
d55f1993e8 Add remove command for reminders 2025-04-08 17:57:50 +02:00
3e5e23591d Update Copilot instructions 2025-03-04 23:10:24 +01:00
0a2fd88cc0 Make Copilot Instructions shorter 2025-03-02 17:46:47 +01:00
e32d149722 Refactor GitHub Copilot instructions 2025-03-02 05:21:28 +01:00
d583154857 Add GitHub Copilot instructions 2025-03-02 05:17:39 +01:00
36dcf8d376 Add get_human_readable_time function and update reminder summary formatting 2025-03-02 04:37:42 +01:00
20 changed files with 1379 additions and 499 deletions

View File

@@ -15,6 +15,15 @@ TIMEZONE=
# On Linux you will need to use double slashes before the path to get the absolute path. # On Linux you will need to use double slashes before the path to get the absolute path.
# SQLITE_LOCATION=//home/lovinator/foo.db # SQLITE_LOCATION=//home/lovinator/foo.db
# Additional directory to store data in.
# Note: You will still need to set the SQLITE_LOCATION to a valid path.
# This is used to store markdown files with the reminder data.
# The directory will be created if it does not exist.
# Example: DATA_DIR=C:/Code/discord-reminder-bot/data
# Example: DATA_DIR=/home/lovinator/data
# Example: DATA_DIR=./data
DATA_DIR=./data
# Log level, CRITICAL, ERROR, WARNING, INFO, DEBUG. # Log level, CRITICAL, ERROR, WARNING, INFO, DEBUG.
LOG_LEVEL=INFO LOG_LEVEL=INFO

9
.github/SECURITY.md vendored
View File

@@ -1,9 +0,0 @@
# Reporting a Vulnerability
You can report a vulnerability by creating an issue on this repo. If you want to report it privately, you can email me at [tlovinator@gmail.com](mailto:tlovinator@gmail.com).
There is also [GitHub Security Advisories](https://github.com/TheLovinator1/discord-reminder-bot/security/advisories/new) if you want to be try-hard.
I am also available on Discord at `TheLovinator#9276`.
Thanks :-)

33
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,33 @@
This is a Discord.py bot that allows you to set date, cron and interval reminders with APScheduler. Dates are parsed using dateparser.
Use try-except blocks, type hints, f-strings, logging, and Google style docstrings.
Add helpful message when using assert in tests.
Docstrings that doesn't return anything should not have a return section.
A function docstring should describe the function's behavior, arguments, side effects, exceptions, return values, and any other information that may be relevant to the user.
Including the exception object in the log message is redundant.
We use GitHub.
Channel reminders have the following kwargs: "channel_id", "message", "author_id".
User DM reminders have the following kwargs: "user_id", "guild_id", "message".
Bot has the following commands:
"/remind add message:<str> time:<str> dm_and_current_channel:<bool> user:<user> channel:<channel>"
"/remind remove id:<job_id>"
"/remind edit id:<job_id>"
"/remind pause_unpause id:<job_id>"
"/remind list"
"/remind cron message:<str> year:<int> month:<int> day:<int> week:<int> day_of_week:<str> hour:<int> minute:<int> second:<int> start_date:<str> end_date:<str> timezone:<str> jitter:<int> channel:<channel> user:<user> dm_and_current_channel:<bool>"
"/remind interval message:<str> weeks:<int> days:<int> hours:<int> minutes:<int> seconds:<int> start_date:<str> end_date:<str> timezone:<str> jitter:<int> channel:<channel> user:<user> dm_and_current_channel:<bool>"

View File

@@ -5,7 +5,7 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 6 * * *" - cron: '0 16 * * 0'
env: env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }} BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
@@ -20,38 +20,41 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- run: |
if [ -z "${{ env.BOT_TOKEN }}" ]; then
echo "BOT_TOKEN not set"
exit 1
fi
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: 3.13
- run: uv sync --all-extras --dev
- run: uv run pytest
- uses: docker/setup-qemu-action@v3
with:
platforms: all
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/login-action@v3 - uses: docker/login-action@v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v7
with:
python-version: 3.13
- run: uv sync --all-extras --dev
- run: uv run pytest
- uses: docker/setup-qemu-action@v3
with:
platforms: all
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64, linux/arm64 platforms: linux/amd64, linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: thelovinator/discord-reminder-bot:latest tags: thelovinator/discord-reminder-bot:latest
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .

View File

@@ -1,38 +0,0 @@
name: Poetry
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "0 6 * * *"
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
TIMEZONE: Europe/Stockholm
LOG_LEVEL: Info
SQLITE_LOCATION: /data/jobs.sqlite
jobs:
test-on-poetry:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
poetry-version: ["latest", "main"]
steps:
- run: |
if [ -z "${{ env.BOT_TOKEN }}" ]; then
echo "BOT_TOKEN not set"
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v4
with:
poetry-version: ${{ matrix.poetry-version }}
- run: poetry install
- run: poetry run pytest

View File

@@ -1,41 +0,0 @@
name: uv
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "0 6 * * *"
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
TIMEZONE: Europe/Stockholm
LOG_LEVEL: Info
SQLITE_LOCATION: /data/jobs.sqlite
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
test-on-uv:
name: Install with uv and run tests on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "pypy"]
steps:
- run: |
if [ -z "${{ env.BOT_TOKEN }}" ]; then
echo "BOT_TOKEN not set"
exit 1
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv and set the python version to ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
version: "latest"
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Run tests
run: uv run pytest

52
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[codz]
*$py.class *$py.class
# C extensions # C extensions
@@ -46,7 +46,7 @@ htmlcov/
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py.cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
@@ -106,17 +106,24 @@ uv.lock
# commonly ignored for libraries. # commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock poetry.lock
poetry.toml
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# in version control. pdm.lock
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control pdm.toml
.pdm.toml
.pdm-python .pdm-python
.pdm-build/ .pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
@@ -129,6 +136,7 @@ celerybeat.pid
# Environments # Environments
.env .env
.envrc
.venv .venv
env/ env/
venv/ venv/
@@ -167,8 +175,38 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# SQLite # SQLite
*.sqlite *.sqlite
*.sqlite.*
data/reminder_data/*

View File

@@ -1,11 +1,11 @@
repos: repos:
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0 rev: v3.2.0
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
- id: check-ast - id: check-ast
@@ -23,13 +23,13 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.19.1 rev: v3.20.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: ["--py310-plus"] args: ["--py310-plus"]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6 rev: v0.13.3
hooks: hooks:
- id: ruff-format - id: ruff-format
- id: ruff - id: ruff

3
.vscode/launch.json vendored
View File

@@ -5,8 +5,7 @@
"name": "Python Debugger: Start bot", "name": "Python Debugger: Start bot",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/discord_reminder_bot/main.py", "module": "discord_reminder_bot.main"
"console": "integratedTerminal",
} }
] ]
} }

View File

@@ -1,6 +1,7 @@
{ {
"cSpell.words": [ "cSpell.words": [
"aiohttp", "aiohttp",
"ambiguious",
"apscheduler", "apscheduler",
"asctime", "asctime",
"asyncio", "asyncio",
@@ -24,14 +25,17 @@
"levelname", "levelname",
"loguru", "loguru",
"Lovinator", "Lovinator",
"McCabe",
"pycodestyle", "pycodestyle",
"pydocstyle", "pydocstyle",
"pyproject", "pyproject",
"pypy", "pypy",
"pytest",
"PYTHONDONTWRITEBYTECODE", "PYTHONDONTWRITEBYTECODE",
"PYTHONUNBUFFERED", "PYTHONUNBUFFERED",
"pyupgrade", "pyupgrade",
"sqlalchemy", "sqlalchemy",
"strptime",
"thelovinator", "thelovinator",
"uvloop" "uvloop"
], ],

View File

@@ -1,13 +1,24 @@
FROM python:3.13-slim # syntax=docker/dockerfile:1
# check=error=true;experimental=all
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN useradd -m botuser && mkdir -p /home/botuser/data RUN useradd -m botuser && mkdir -p /home/botuser/data
WORKDIR /home/botuser WORKDIR /home/botuser
COPY interactions /home/botuser/interactions COPY interactions /home/botuser/interactions
COPY discord_reminder_bot /home/botuser/discord_reminder_bot COPY discord_reminder_bot /home/botuser/discord_reminder_bot
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --no-install-project uv sync --no-install-project
ENV DATA_DIR=/home/botuser/data
ENV SQLITE_LOCATION=/data/jobs.sqlite
VOLUME ["/home/botuser/data/"] VOLUME ["/home/botuser/data/"]
CMD ["uv", "run", "discord_reminder_bot/main.py"] CMD ["uv", "run", "python", "-m", "discord_reminder_bot.main"]

View File

@@ -31,30 +31,18 @@ using [Docker](https://hub.docker.com/r/thelovinator/discord-reminder-bot).
### Install directly on your computer ### Install directly on your computer
- Install the latest version of needed software: - Install the latest version of needed software:
- [Python](https://www.python.org/) - `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
- You should use the latest version.
- You want to add Python to your PATH.
- Windows: Find `App execution aliases` and disable python.exe and python3.exe
- [Poetry](https://python-poetry.org/docs/master/#installation)
- Windows: You have to add `%appdata%\Python\Scripts` to your PATH for Poetry to work.
- Download project from GitHub with Git or download - Download project from GitHub with Git or download
the [ZIP](https://github.com/TheLovinator1/discord-reminder-bot/archive/refs/heads/master.zip). the [ZIP](https://github.com/TheLovinator1/discord-reminder-bot/archive/refs/heads/master.zip).
- If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again. - If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again.
- Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad). - Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad).
- If you can't see the file extension: - If you can't see the file extension:
- Windows 10: Click the View Tab in File Explorer and click the box next to File name extensions.
- Windows 11: Click View -> Show -> File name extensions. - Windows 11: Click View -> Show -> File name extensions.
- Open a terminal in the repository folder. - Open a terminal in the repository folder.
- Windows 10: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and select `Open PowerShell window here`
- Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options - Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options
and `Open PowerShell window here` and `Open PowerShell window here`
- Install requirements:
- Type `poetry install` into the PowerShell window. Make sure you are
in the repository folder with the [pyproject.toml](pyproject.toml) file.
- You may have to restart your terminal if it can't find the `poetry` command. Also double check it is in
your PATH.
- Start the bot: - Start the bot:
- Type `poetry run bot` into the PowerShell window. - Type `uv run .\discord_reminder_bot\main.py` into the PowerShell window.
- You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>. - You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>.
Note: You will need to run `poetry install` again if poetry.lock has been modified. Note: You will need to run `poetry install` again if poetry.lock has been modified.

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import datetime
import json
import os
from typing import Any
from zoneinfo import ZoneInfo
import dateparser
from apscheduler.job import Job
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from loguru import logger
from interactions.api.models.misc import Snowflake
def calculate(job: Job) -> str:
"""Calculate the time left for a job.
Args:
job: The job to calculate the time for.
Returns:
str: The time left for the job or "Paused" if the job is paused or has no next run time.
"""
trigger_time = None
if isinstance(job.trigger, DateTrigger | IntervalTrigger):
trigger_time = job.next_run_time or None
elif isinstance(job.trigger, CronTrigger):
if not job.next_run_time:
logger.debug(f"No next run time found for '{job.id}', probably paused? {job.__getstate__()}")
return "Paused"
trigger_time = job.trigger.get_next_fire_time(None, datetime.datetime.now(tz=job._scheduler.timezone)) # noqa: SLF001
logger.debug(f"{type(job.trigger)=}, {trigger_time=}")
if not trigger_time:
logger.debug("No trigger time found")
return "Paused"
return f"<t:{int(trigger_time.timestamp())}:R>"
def get_human_readable_time(job: Job) -> str:
"""Get the human-readable time for a job.
Args:
job: The job to get the time for.
Returns:
str: The human-readable time.
"""
trigger_time = None
if isinstance(job.trigger, DateTrigger | IntervalTrigger):
trigger_time = job.next_run_time or None
elif isinstance(job.trigger, CronTrigger):
if not job.next_run_time:
logger.debug(f"No next run time found for '{job.id}', probably paused? {job.__getstate__()}")
return "Paused"
trigger_time = job.trigger.get_next_fire_time(None, datetime.datetime.now(tz=job._scheduler.timezone)) # noqa: SLF001
if not trigger_time:
logger.debug("No trigger time found")
return "Paused"
return trigger_time.strftime("%Y-%m-%d %H:%M:%S")
def parse_time(date_to_parse: str | None, timezone: str | None = os.getenv("TIMEZONE")) -> datetime.datetime | None:
"""Parse a date string into a datetime object.
Args:
date_to_parse(str): The date string to parse.
timezone(str, optional): The timezone to use. Defaults to the TIMEZONE environment variable.
Returns:
datetime.datetime: The parsed datetime object.
"""
if not date_to_parse:
logger.error("No date provided to parse.")
return None
if not timezone:
logger.error("No timezone provided to parse date.")
return None
logger.info(f"Parsing date: '{date_to_parse}' with timezone: '{timezone}'")
try:
parsed_date: datetime.datetime | None = dateparser.parse(
date_string=date_to_parse,
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}",
"RETURN_AS_TIMEZONE_AWARE": True,
"RELATIVE_BASE": datetime.datetime.now(tz=ZoneInfo(str(timezone))),
},
)
except (ValueError, TypeError) as e:
logger.error(f"Failed to parse date: '{date_to_parse}' with timezone: '{timezone}'. Error: {e}")
return None
logger.debug(f"Parsed date: {parsed_date} from '{date_to_parse}'")
return parsed_date
def generate_state(state: dict[str, Any], job: Job) -> str:
"""Format the __getstate__ dictionary for Discord markdown.
Args:
state (dict): The __getstate__ dictionary.
job (Job): The APScheduler job.
Returns:
str: The formatted string.
"""
if not state:
logger.error(f"No state found for {job.id}")
return "No state found.\n"
for key, value in state.items():
if isinstance(value, IntervalTrigger):
state[key] = "IntervalTrigger"
elif isinstance(value, DateTrigger):
state[key] = "DateTrigger"
elif isinstance(value, Job):
state[key] = "Job"
elif isinstance(value, Snowflake):
state[key] = str(value)
try:
msg: str = json.dumps(state, indent=4, default=str)
except TypeError as e:
e.add_note("This is likely due to a non-serializable object in the state. Please check the state for any non-serializable objects.")
e.add_note(f"{state=}")
logger.error(f"Failed to serialize state: {e}")
return "Failed to serialize state."
return msg
def generate_markdown_state(state: dict[str, Any], job: Job) -> str:
"""Format the __getstate__ dictionary for Discord markdown.
Args:
state (dict): The __getstate__ dictionary.
job (Job): The APScheduler job.
Returns:
str: The formatted string.
"""
msg: str = generate_state(state=state, job=job)
return "```json\n" + msg + "\n```"

View File

@@ -1,153 +1,45 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime import datetime
import json import json
import os import os
import platform import platform
import sys
import tempfile import tempfile
from functools import lru_cache from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateparser import apscheduler.triggers.cron
import apscheduler.triggers.date
import apscheduler.triggers.interval
import discord import discord
import pytz
import sentry_sdk import sentry_sdk
from apscheduler import events from apscheduler import events
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, JobExecutionEvent from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, JobExecutionEvent
from apscheduler.job import Job from apscheduler.jobstores.base import JobLookupError
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from discord.abc import PrivateChannel from discord.abc import PrivateChannel
from discord.utils import escape_markdown
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from dotenv import load_dotenv
from loguru import logger from loguru import logger
from sentry_sdk.integrations.asyncio import AsyncioIntegration
from sentry_sdk.integrations.loguru import LoggingLevels, LoguruIntegration
from sentry_sdk.integrations.sys_exit import SysExitIntegration
from discord_reminder_bot.helpers import calculate, generate_markdown_state, generate_state, get_human_readable_time, parse_time
from discord_reminder_bot.modals import CronReminderModifyModal, DateReminderModifyModal, IntervalReminderModifyModal
from discord_reminder_bot.settings import export_reminder_jobs_to_markdown, get_markdown_contents_from_markdown_file, scheduler
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from types import CoroutineType
from apscheduler.job import Job from apscheduler.job import Job
from discord.guild import GuildChannel from discord.guild import GuildChannel
from discord.interactions import InteractionChannel from discord.interactions import InteractionChannel
from discord.types.channel import _BaseChannel
from requests import Response from requests import Response
from sentry_sdk.types import Hint, Log
load_dotenv()
default_sentry_dsn: str = "https://c4c61a52838be9b5042144420fba5aaa@o4505228040339456.ingest.us.sentry.io/4508707268984832"
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN", default_sentry_dsn),
environment=platform.node() or "Unknown",
traces_sample_rate=1.0,
send_default_pii=True,
)
def parse_time(date_to_parse: str | None, timezone: str | None = os.getenv("TIMEZONE")) -> datetime.datetime | None:
"""Parse a date string into a datetime object.
Args:
date_to_parse(str): The date string to parse.
timezone(str, optional): The timezone to use. Defaults to the TIMEZONE environment variable.
Returns:
datetime.datetime: The parsed datetime object.
"""
if not date_to_parse:
logger.error("No date provided to parse.")
return None
if not timezone:
logger.error("No timezone provided to parse date.")
return None
logger.info(f"Parsing date: '{date_to_parse}' with timezone: '{timezone}'")
try:
parsed_date: datetime.datetime | None = dateparser.parse(
date_string=date_to_parse,
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}",
"RETURN_AS_TIMEZONE_AWARE": True,
"RELATIVE_BASE": datetime.datetime.now(tz=ZoneInfo(str(timezone))),
},
)
except (ValueError, TypeError) as e:
logger.error(f"Failed to parse date: '{date_to_parse}' with timezone: '{timezone}'. Error: {e}")
return None
logger.debug(f"Parsed date: {parsed_date} from '{date_to_parse}'")
return parsed_date
def calculate(job: Job) -> str:
"""Calculate the time left for a job.
Args:
job: The job to calculate the time for.
Returns:
str: The time left for the job or "Paused" if the job is paused or has no next run time.
"""
trigger_time = None
if isinstance(job.trigger, DateTrigger | IntervalTrigger):
trigger_time = job.next_run_time or None
elif isinstance(job.trigger, CronTrigger):
if not job.next_run_time:
logger.debug(f"No next run time found for '{job.id}', probably paused? {job.__getstate__()}")
return "Paused"
trigger_time = job.trigger.get_next_fire_time(None, datetime.datetime.now(tz=job._scheduler.timezone)) # noqa: SLF001
logger.debug(f"{type(job.trigger)=}, {trigger_time=}")
if not trigger_time:
logger.debug("No trigger time found")
return "Paused"
return f"<t:{int(trigger_time.timestamp())}:R>"
@lru_cache(maxsize=1)
def get_scheduler() -> AsyncIOScheduler:
"""Return the scheduler instance.
Uses the SQLITE_LOCATION environment variable for the SQLite database location.
Raises:
ValueError: If the timezone is missing or invalid.
Returns:
AsyncIOScheduler: The scheduler instance.
"""
config_timezone: str | None = os.getenv("TIMEZONE")
if not config_timezone:
msg = "Missing timezone. Please set the TIMEZONE environment variable."
raise ValueError(msg)
# Test if the timezone is valid
try:
ZoneInfo(config_timezone)
except (ZoneInfoNotFoundError, ModuleNotFoundError) as e:
msg: str = f"Invalid timezone: {config_timezone}. Error: {e}"
raise ValueError(msg) from e
sqlite_location: str = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite")
logger.info(f"Using SQLite database at: {sqlite_location}")
jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")}
job_defaults: dict[str, bool] = {"coalesce": True}
timezone = pytz.timezone(config_timezone)
return AsyncIOScheduler(jobstores=jobstores, timezone=timezone, job_defaults=job_defaults)
scheduler: AsyncIOScheduler = get_scheduler()
def my_listener(event: JobExecutionEvent) -> None: def my_listener(event: JobExecutionEvent) -> None:
@@ -156,19 +48,46 @@ def my_listener(event: JobExecutionEvent) -> None:
Args: Args:
event: The event that occurred. event: The event that occurred.
""" """
if event.code == events.EVENT_JOB_ADDED:
export_reminder_jobs_to_markdown()
if event.code == events.EVENT_JOB_MISSED: if event.code == events.EVENT_JOB_MISSED:
scheduled_time: str = event.scheduled_run_time.strftime("%Y-%m-%d %H:%M:%S") scheduled_time: str = event.scheduled_run_time.strftime("%Y-%m-%d %H:%M:%S")
msg: str = f"Job {event.job_id} was missed! Was scheduled at {scheduled_time}"
send_webhook(message=msg) markdown_time: str = f"(<t:{int(event.scheduled_run_time.timestamp())}:R>)" if event.scheduled_run_time else ""
# Get data from markdown file that was created by export_reminder_jobs_to_markdown()
job_data: str = get_markdown_contents_from_markdown_file(event.job_id)
if not job_data:
msg: str = f"Job {event.job_id} was missed! Was scheduled at {scheduled_time} {markdown_time}\n"
logger.warning(msg)
else:
msg: str = f"Job {event.job_id} was missed! Was scheduled at {scheduled_time} {markdown_time}\nData:\n```json\n{job_data}\n```"
logger.warning(msg)
send_webhook(custom_url="", message=msg)
if event.exception: if event.exception:
with sentry_sdk.push_scope() as scope: with sentry_sdk.new_scope() as scope:
scope.set_extra("job_id", event.job_id) scope.set_extra("job_id", event.job_id)
scope.set_extra("scheduled_run_time", event.scheduled_run_time.isoformat() if event.scheduled_run_time else "None") scope.set_extra("scheduled_run_time", event.scheduled_run_time.isoformat() if event.scheduled_run_time else "None")
scope.set_extra("event_code", event.code) scope.set_extra("event_code", event.code)
scope.set_extra("bot_is_ready", bot.is_ready() if "bot" in globals() else "Unknown")
scope.set_extra("bot_is_closed", bot.is_closed() if "bot" in globals() else "Unknown")
sentry_sdk.capture_exception(event.exception) sentry_sdk.capture_exception(event.exception)
send_webhook(f"discord-reminder-bot failed to send message to Discord\n{event}") # Create detailed error message with bot state information
bot_state_info = ""
if "bot" in globals():
bot_state_info = f"\nBot State: ready={bot.is_ready()}, closed={bot.is_closed()}, user={bot.user}"
if hasattr(bot, "http") and bot.http:
global_over = getattr(bot.http, "_global_over", None)
bot_state_info += f"\nHTTP State: _global_over type={type(global_over)}"
send_webhook(
custom_url="",
message=f"discord-reminder-bot failed to send message to Discord\nJob ID: {event.job_id}\nScheduled Time: {event.scheduled_run_time.isoformat() if event.scheduled_run_time else 'None'}{bot_state_info}\n{event.exception}\n{event.traceback}",
)
class RemindBotClient(discord.Client): class RemindBotClient(discord.Client):
@@ -180,65 +99,85 @@ class RemindBotClient(discord.Client):
Args: Args:
intents: The intents to use. intents: The intents to use.
""" """
super().__init__(intents=intents) super().__init__(intents=intents, max_messages=None)
self.tree = discord.app_commands.CommandTree(self) self.tree = discord.app_commands.CommandTree(self)
async def on_error(self, event_method: str, *args: list[Any], **kwargs: dict[str, Any]) -> None: async def on_error(self, event_method: str, *args: list[Any], **kwargs: dict[str, Any]) -> None:
"""Log errors that occur in the bot.""" """Log errors that occur in the bot."""
# Log the error
logger.exception(f"An error occurred in {event_method} with args: {args} and kwargs: {kwargs}") logger.exception(f"An error occurred in {event_method} with args: {args} and kwargs: {kwargs}")
# Add context to Sentry with sentry_sdk.new_scope() as scope:
with sentry_sdk.push_scope() as scope: scope.set_extra("event_method", event_method)
# Add event details
scope.set_tag("event_method", event_method)
scope.set_extra("args", args) scope.set_extra("args", args)
scope.set_extra("kwargs", kwargs) scope.set_extra("kwargs", kwargs)
scope.set_extra("bot_is_ready", self.is_ready())
# Add bot state scope.set_extra("bot_is_closed", self.is_closed())
scope.set_tag("bot_user_id", self.user.id if self.user else "Unknown") if hasattr(self, "ws") and self.ws:
scope.set_tag("bot_user_name", str(self.user) if self.user else "Unknown") scope.set_extra("session_id", self.ws.session_id)
scope.set_tag("bot_latency", self.latency)
# If specific arguments are available, extract and add details
if args:
interaction = next((arg for arg in args if isinstance(arg, discord.Interaction)), None)
if interaction:
scope.set_extra("interaction_id", interaction.id)
scope.set_extra("interaction_user", interaction.user.id)
scope.set_extra("interaction_user_tag", str(interaction.user))
scope.set_extra("interaction_command", interaction.command.name if interaction.command else None)
scope.set_extra("interaction_channel", str(interaction.channel))
scope.set_extra("interaction_guild", str(interaction.guild) if interaction.guild else None)
# Add Sentry tags for interaction details
scope.set_tag("interaction_id", interaction.id)
scope.set_tag("interaction_user_id", interaction.user.id)
scope.set_tag("interaction_user_tag", str(interaction.user))
scope.set_tag("interaction_command", interaction.command.name if interaction.command else "None")
scope.set_tag("interaction_channel_id", interaction.channel.id if interaction.channel else "None")
scope.set_tag("interaction_channel_name", str(interaction.channel))
scope.set_tag("interaction_guild_id", interaction.guild.id if interaction.guild else "None")
scope.set_tag("interaction_guild_name", str(interaction.guild) if interaction.guild else "None")
# Add APScheduler context
scope.set_extra("scheduler_jobs", [job.id for job in scheduler.get_jobs()])
sentry_sdk.capture_exception() sentry_sdk.capture_exception()
async def on_ready(self) -> None: async def on_ready(self) -> None:
"""Log when the bot is ready.""" """Called when the client is done preparing the data received from Discord. Usually after login is successful and the Client.guilds and co. are filled up.
Warning:
This function is not guaranteed to be the first event called. Likewise, this function is not guaranteed to only be called once.
discord.py implements reconnection logic and thus will end up calling this event whenever a RESUME request fails.
"""
logger_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | {extra[session_id]} | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
logger.configure(extra={"session_id": self.ws.session_id})
logger.remove()
logger.add(sys.stderr, format=logger_format)
logger.info(f"Logged in as {self.user} ({self.user.id if self.user else 'Unknown'})") logger.info(f"Logged in as {self.user} ({self.user.id if self.user else 'Unknown'})")
async def setup_hook(self) -> None: async def setup_hook(self) -> None:
"""Setup the bot.""" """Setup the bot."""
scheduler.start() default_sentry_dsn: str = "https://c4c61a52838be9b5042144420fba5aaa@o4505228040339456.ingest.us.sentry.io/4508707268984832"
def before_send_log(log: Log, _hint: Hint) -> Log | None:
"""Filter out unwanted log messages before sending to Sentry.
Args:
log: The log object containing message and metadata.
_hint: Additional context about the log.
Returns:
The log object if it should be sent to Sentry, None to discard it.
"""
ignored_log_messages: list[str] = [
"has connected to Gateway",
"has successfully RESUMED session",
]
if log.get("body") and any(noisy_log in log["body"] for noisy_log in ignored_log_messages):
return None
return log
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN", default_sentry_dsn),
environment=platform.node() or "Unknown",
traces_sample_rate=1.0,
profile_session_sample_rate=1.0,
send_default_pii=True,
_experiments={
"enable_logs": True,
"before_send_log": before_send_log,
},
integrations=[
AsyncioIntegration(),
LoguruIntegration(sentry_logs_level=LoggingLevels.WARNING.value),
SysExitIntegration(capture_successful_exits=True),
],
)
scheduler.add_listener(my_listener, EVENT_JOB_MISSED | EVENT_JOB_ERROR) scheduler.add_listener(my_listener, EVENT_JOB_MISSED | EVENT_JOB_ERROR)
jobs: list[Job] = scheduler.get_jobs() jobs: list[Job] = scheduler.get_jobs()
if not jobs: if jobs:
logger.info("No jobs available.")
return
logger.info("Jobs available:") logger.info("Jobs available:")
try: try:
for job in jobs: for job in jobs:
@@ -252,91 +191,296 @@ class RemindBotClient(discord.Client):
logger.exception("Failed to loop through jobs") logger.exception("Failed to loop through jobs")
await self.tree.sync() await self.tree.sync()
logger.info("Command tree synced.")
if not scheduler.running:
logger.info("Starting scheduler.")
scheduler.start()
else:
logger.error("Scheduler is already running.")
export_reminder_jobs_to_markdown()
def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: PLR0912 def format_job_for_ui(job: Job) -> str:
"""Create a message with all the jobs, splitting messages into chunks of up to 2000 characters. """Format a single job for display in the UI.
Args: Args:
ctx (discord.Interaction): The context of the interaction. job (Job): The job to format.
Returns: Returns:
list[str]: A list of messages with all the jobs. str: The formatted string.
""" """
jobs: list[Job] = scheduler.get_jobs() msg: str = f"\nMessage: {job.kwargs.get('message', '')}\n"
msgs: list[str] = [] msg += f"ID: {job.id}\n"
msg += f"Trigger: {job.trigger} {get_human_readable_time(job)}\n"
guild: discord.Guild | None = None
if isinstance(ctx.channel, discord.abc.GuildChannel):
guild = ctx.channel.guild
channels: list[GuildChannel] | list[_BaseChannel] = list(guild.channels) if guild else []
list_of_channels_in_current_guild: list[int] = [c.id for c in channels]
jobs_in_guild: list[Job] = []
for job in jobs:
guild_id: int = guild.id if guild else 0
guild_id_from_kwargs: int | None = job.kwargs.get("guild_id")
channel_id_from_kwargs: int | None = job.kwargs.get("channel_id")
if guild_id_from_kwargs != guild_id:
logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
continue
if channel_id_from_kwargs not in list_of_channels_in_current_guild:
logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
continue
jobs_in_guild.append(job)
if len(jobs) != len(jobs_in_guild):
logger.info(f"Filtered out {len(jobs) - len(jobs_in_guild)} jobs that are not in the current guild.")
jobs = jobs_in_guild
if not jobs:
return ["No scheduled jobs found in the database."]
header = (
"You can use the following commands to manage reminders:\n"
"Only jobs in the current guild are shown.\n"
"`/remind pause <job_id>` - Pause a reminder\n"
"`/remind unpause <job_id>` - Unpause a reminder\n"
"`/remind remove <job_id>` - Remove a reminder\n"
"`/remind modify <job_id>` - Modify the time of a reminder\n"
"List of all reminders:\n"
)
current_msg: str = header
for job in jobs:
# Build job-specific message
job_msg: str = "```md\n"
job_msg += f"# {job.kwargs.get('message', '')}\n"
job_msg += f" * {job.id}\n"
job_msg += f" * {job.trigger} {calculate(job)}"
if job.kwargs.get("user_id"): if job.kwargs.get("user_id"):
job_msg += f" <@{job.kwargs.get('user_id')}>" msg += f"User: <@{job.kwargs.get('user_id')}>\n"
if job.kwargs.get("channel_id"):
job_msg += f" <#{job.kwargs.get('channel_id')}>"
if job.kwargs.get("guild_id"): if job.kwargs.get("guild_id"):
job_msg += f" {job.kwargs.get('guild_id')}" guild_id: int = job.kwargs.get("guild_id")
msg += f"Guild: {guild_id}\n"
if job.kwargs.get("author_id"):
author_id: int = job.kwargs.get("author_id")
msg += f"Author: <@{author_id}>\n"
if job.kwargs.get("channel_id"):
channel = bot.get_channel(job.kwargs.get("channel_id"))
if channel and isinstance(channel, discord.abc.GuildChannel | discord.Thread):
msg += f"Channel: #{channel.name}\n"
job_msg += "```" msg += f"\nData:\n{generate_state(job.__getstate__(), job)}\n"
# If adding this job exceeds 2000 characters, push the current message and start a new one. if isinstance(job.trigger, apscheduler.triggers.interval.IntervalTrigger):
if len(current_msg) + len(job_msg) > 2000: msg += (
msgs.append(current_msg) "\nNote: This is an interval job. Due to UI limitations, you can only modify the message, not the trigger settings.\n"
current_msg = job_msg "To change the trigger settings, please delete and recreate the job.\n"
)
elif isinstance(job.trigger, apscheduler.triggers.cron.CronTrigger):
msg += (
"\nNote: This is a cron job. Due to UI limitations, you can only modify the message, not the trigger settings.\n"
"To change the trigger settings, please delete and recreate the job.\n"
)
logger.debug(f"Formatted job for UI: {msg}")
return msg
class ReminderListView(discord.ui.View):
"""A view for listing reminders with pagination and action buttons."""
def __init__(self, jobs: list[Job], interaction: discord.Interaction, jobs_per_page: int = 1) -> None:
"""Initialize the view with a list of jobs and interaction.
Args:
jobs (list[Job]): The list of jobs to display.
interaction (discord.Interaction): The interaction that triggered this view.
jobs_per_page (int): The number of jobs to display per page. Defaults to 1.
"""
super().__init__(timeout=180)
self.jobs: list[Job] = jobs
self.interaction: discord.Interaction[discord.Client] = interaction
self.jobs_per_page: int = jobs_per_page
self.current_page = 0
self.message: discord.InteractionMessage | None = None
self.update_view()
@property
def total_pages(self) -> int:
"""Calculate the total number of pages based on the number of jobs and jobs per page."""
return max(1, (len(self.jobs) + self.jobs_per_page - 1) // self.jobs_per_page)
def update_view(self) -> None:
"""Update the buttons and job actions for the current page."""
self.clear_items()
# Ensure current_page is in valid bounds
self.current_page: int = max(0, min(self.current_page, self.total_pages - 1))
# Pagination buttons
buttons: list[tuple[str, Callable[..., CoroutineType[Any, Any, None]], bool] | tuple[str, None, bool]] = [
("⏮️", self.goto_first_page, self.current_page == 0),
("◀️", self.goto_prev_page, self.current_page == 0),
(f"{self.current_page + 1}/{self.total_pages}", None, True),
("▶️", self.goto_next_page, self.current_page >= self.total_pages - 1),
("⏭️", self.goto_last_page, self.current_page >= self.total_pages - 1),
]
for label, callback, disabled in buttons:
btn = discord.ui.Button(label=label, style=discord.ButtonStyle.secondary, disabled=disabled)
if callback:
btn.callback = callback
self.add_item(btn)
# Job action buttons
start: int = self.current_page * self.jobs_per_page
end: int = min(start + self.jobs_per_page, len(self.jobs))
for i, job in enumerate(self.jobs[start:end]):
row: int = i + 1 # pagination is row 0
job_id = job.id
label: str = "▶️ Unpause" if job.next_run_time is None else "⏸️ Pause"
delete = discord.ui.Button(label="🗑️ Delete", style=discord.ButtonStyle.danger, row=row)
delete.callback = partial(self.handle_delete, job_id=job_id)
modify = discord.ui.Button(label="✏️ Modify", style=discord.ButtonStyle.secondary, row=row)
modify.callback = partial(self.handle_modify, job_id=job_id)
pause = discord.ui.Button(label=label, style=discord.ButtonStyle.success, row=row)
pause.callback = partial(self.handle_pause_unpause, job_id=job_id)
self.add_item(delete)
self.add_item(modify)
self.add_item(pause)
def get_page_content(self) -> str:
"""Get the content for the current page of reminders.
Returns:
str: The formatted string for the current page.
"""
start: int = self.current_page * self.jobs_per_page
end: int = min(start + self.jobs_per_page, len(self.jobs))
jobs: list[Job] = self.jobs[start:end]
if not jobs:
return "No reminders found on this page."
job: Job = jobs[0]
return f"```{format_job_for_ui(job)}```"
async def refresh(self, interaction: discord.Interaction) -> None:
"""Refresh the view and update the message with the current page content.
Args:
interaction (discord.Interaction): The interaction that triggered this refresh.
"""
self.update_view()
if self.message:
await self.message.edit(content=self.get_page_content(), view=self)
else: else:
current_msg += job_msg await interaction.response.edit_message(content=self.get_page_content(), view=self)
# Append any remaining content in current_msg. async def goto_first_page(self, interaction: discord.Interaction) -> None:
if current_msg: """Go to the first page of reminders."""
msgs.append(current_msg) await interaction.response.defer()
self.current_page = 0
await self.refresh(interaction)
return msgs async def goto_prev_page(self, interaction: discord.Interaction) -> None:
"""Go to the previous page of reminders."""
await interaction.response.defer()
self.current_page -= 1
await self.refresh(interaction)
async def goto_next_page(self, interaction: discord.Interaction) -> None:
"""Go to the next page of reminders."""
await interaction.response.defer()
self.current_page += 1
await self.refresh(interaction)
async def goto_last_page(self, interaction: discord.Interaction) -> None:
"""Go to the last page of reminders."""
await interaction.response.defer()
self.current_page = self.total_pages - 1
await self.refresh(interaction)
async def handle_delete(self, interaction: discord.Interaction, job_id: str) -> None:
"""Handle the deletion of a reminder job.
Args:
interaction (discord.Interaction): The interaction that triggered this deletion.
job_id (str): The ID of the job to delete.
"""
await interaction.response.defer(ephemeral=True)
try:
scheduler.remove_job(job_id)
self.jobs = [job for job in self.jobs if job.id != job_id]
await interaction.followup.send(f"Reminder `{escape_markdown(job_id)}` deleted.", ephemeral=True)
if (
not self.jobs[self.current_page * self.jobs_per_page : (self.current_page + 1) * self.jobs_per_page]
and self.current_page > 0
):
self.current_page -= 1
except JobLookupError:
await interaction.followup.send(f"Job `{escape_markdown(job_id)}` not found.", ephemeral=True)
except Exception as e: # noqa: BLE001
logger.exception(f"Failed to delete job {job_id}: {e}")
await interaction.followup.send(f"Failed to delete job `{escape_markdown(job_id)}`.", ephemeral=True)
await self.refresh(interaction)
async def handle_modify(self, interaction: discord.Interaction, job_id: str) -> None:
"""Handle the modification of a reminder job.
Args:
interaction (discord.Interaction): The interaction that triggered this modification.
job_id (str): The ID of the job to modify.
"""
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.response.send_message(f"Failed to get job for '{job_id}'", ephemeral=True)
return
# Check if the job is a date-based job
if isinstance(job.trigger, apscheduler.triggers.date.DateTrigger):
await interaction.response.send_modal(DateReminderModifyModal(job))
return
if isinstance(job.trigger, apscheduler.triggers.cron.CronTrigger):
await interaction.response.send_modal(CronReminderModifyModal(job))
return
if isinstance(job.trigger, apscheduler.triggers.interval.IntervalTrigger):
await interaction.response.send_modal(IntervalReminderModifyModal(job))
return
logger.error(f"Job {job_id} is not a date-based job, cron job, or interval job. Cannot modify.")
await interaction.response.send_message(
f"Job is not a date-based job, cron job, or interval job. Cannot modify.\n"
f"Job ID: `{escape_markdown(job_id)}`\n"
f"Job Trigger: `{job.trigger}`",
ephemeral=True,
)
await self.refresh(interaction)
async def handle_pause_unpause(self, interaction: discord.Interaction, job_id: str) -> None:
"""Handle pausing or unpausing a reminder job.
Args:
interaction (discord.Interaction): The interaction that triggered this action.
job_id (str): The ID of the job to pause or unpause.
"""
await interaction.response.defer(ephemeral=True)
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(f"Job `{escape_markdown(job_id)}` not found.", ephemeral=True)
return
if job.next_run_time is None:
scheduler.resume_job(job_id)
msg = f"Reminder `{escape_markdown(job_id)}` unpaused."
else:
scheduler.pause_job(job_id)
msg = f"Reminder `{escape_markdown(job_id)}` paused."
# Update only the affected job in self.jobs
updated_job = scheduler.get_job(job_id)
if updated_job:
for i, j in enumerate(self.jobs):
if j.id == job_id:
self.jobs[i] = updated_job
break
await interaction.followup.send(msg, ephemeral=True)
except JobLookupError:
await interaction.followup.send(f"Job `{escape_markdown(job_id)}` not found.", ephemeral=True)
except Exception as e: # noqa: BLE001
logger.exception(f"Failed to pause/unpause job {job_id}: {e}")
await interaction.followup.send(f"Failed to pause/unpause job `{escape_markdown(job_id)}`.", ephemeral=True)
await self.refresh(interaction)
async def on_timeout(self) -> None:
"""Handle the timeout of the view."""
logger.info("ReminderListView timed out, disabling buttons.")
if self.message:
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
await self.message.edit(view=self)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if the interaction is valid for this view.
Args:
interaction (discord.Interaction): The interaction to check.
Returns:
bool: True if the interaction is valid, False otherwise.
"""
if interaction.user != self.interaction.user:
logger.debug(f"Interaction user {interaction.user} is not the same as the view's interaction user {self.interaction.user}.")
await interaction.response.send_message("This is not your reminder list!", ephemeral=True)
return False
return True
class RemindGroup(discord.app_commands.Group): class RemindGroup(discord.app_commands.Group):
@@ -548,25 +692,33 @@ class RemindGroup(discord.app_commands.Group):
logger.info(f"Listing reminders for {user} ({user.id}) in {interaction.channel}") logger.info(f"Listing reminders for {user} ({user.id}) in {interaction.channel}")
logger.info(f"Arguments: {locals()}") logger.info(f"Arguments: {locals()}")
jobs: list[Job] = scheduler.get_jobs() all_jobs: list[Job] = scheduler.get_jobs()
if not jobs:
await interaction.followup.send(content="No scheduled jobs found in the database.", ephemeral=True)
return
guild: discord.Guild | None = interaction.guild guild: discord.Guild | None = interaction.guild
if not guild: if not guild:
await interaction.followup.send(content="Failed to get guild.", ephemeral=True) await interaction.followup.send(content="Failed to get guild.", ephemeral=True)
return return
message: discord.InteractionMessage = await interaction.original_response() # Filter jobs by guild
guild_jobs: list[Job] = []
channels_in_this_guild: list[int] = [c.id for c in guild.channels] if guild else []
for job in all_jobs:
guild_id_from_kwargs = int(job.kwargs.get("guild_id", 0))
if guild_id_from_kwargs and guild_id_from_kwargs != guild.id:
continue
job_summary: list[str] = generate_reminder_summary(ctx=interaction) if job.kwargs.get("channel_id") not in channels_in_this_guild:
continue
for i, msg in enumerate(job_summary): guild_jobs.append(job)
if i == 0:
await message.edit(content=msg) if not guild_jobs:
else: await interaction.followup.send(content="No scheduled jobs found in this server.", ephemeral=True)
await interaction.followup.send(content=msg) return
view = ReminderListView(jobs=guild_jobs, interaction=interaction)
content = view.get_page_content()
message = await interaction.followup.send(content=content, view=view)
view.message = message # Store the message for later edits
# /remind cron # /remind cron
@discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.") @discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.")
@@ -786,13 +938,14 @@ class RemindGroup(discord.app_commands.Group):
}, },
) )
dm_message = f" and a DM to {user.display_name} " dm_message = f" and a DM to {user.display_name}"
if not dm_and_current_channel: if not dm_and_current_channel:
await interaction.followup.send( await interaction.followup.send(
content=f"Hello {interaction.user.display_name},\n" content=f"Hello {interaction.user.display_name},\n"
f"I will send a DM to {user.display_name} at:\n" f"I will send a DM to {user.display_name} at:\n"
f"First run in {calculate(dm_job)} with the message:\n**{message}**.", f"First run in {calculate(dm_job)} with the message:\n**{message}**.",
) )
return
# Create channel reminder job # Create channel reminder job
channel_job: Job = scheduler.add_job( channel_job: Job = scheduler.add_job(
@@ -862,6 +1015,7 @@ class RemindGroup(discord.app_commands.Group):
return return
# Can't be 0 because that's the default value for jobs without a guild # Can't be 0 because that's the default value for jobs without a guild
# TODO(TheLovinator): This will probably fuck me in the ass in the future, so should probably not be -1 or 0. # noqa: TD003
guild_id: int = interaction.guild.id if interaction.guild else -1 guild_id: int = interaction.guild.id if interaction.guild else -1
channels_in_this_guild: list[int] = [c.id for c in interaction.guild.channels] if interaction.guild else [] channels_in_this_guild: list[int] = [c.id for c in interaction.guild.channels] if interaction.guild else []
logger.debug(f"Guild ID: {guild_id}") logger.debug(f"Guild ID: {guild_id}")
@@ -878,6 +1032,12 @@ class RemindGroup(discord.app_commands.Group):
logger.debug(f"Skipping job: {job.get('id')} because it's not in the current guild.") logger.debug(f"Skipping job: {job.get('id')} because it's not in the current guild.")
jobs_data["jobs"].remove(job) jobs_data["jobs"].remove(job)
# If we have no jobs left, return an error message
if not jobs_data.get("jobs"):
msg: str = "No reminders found in this server." if not all_servers else "No reminders found."
await interaction.followup.send(content=msg, ephemeral=True)
return
msg: str = "All reminders in this server have been backed up." if not all_servers else "All reminders have been backed up." msg: str = "All reminders in this server have been backed up." if not all_servers else "All reminders have been backed up."
msg += "\nYou can restore them using `/remind restore`." msg += "\nYou can restore them using `/remind restore`."
@@ -990,6 +1150,92 @@ class RemindGroup(discord.app_commands.Group):
else: else:
await interaction.followup.send(content="No new reminders were added.") await interaction.followup.send(content="No new reminders were added.")
# /remind remove
@discord.app_commands.command(name="remove", description="Remove a reminder")
async def remove(self, interaction: discord.Interaction, job_id: str) -> None:
"""Remove a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to remove.
"""
await interaction.response.defer()
logger.debug(f"Removing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.remove_job(job_id)
logger.info(f"Removed job {job_id}. {job.__getstate__()}")
await interaction.followup.send(
content=f"Reminder with ID {job_id} removed successfully.\n{generate_markdown_state(job.__getstate__(), job=job)}",
)
except JobLookupError as e:
logger.exception(f"Failed to remove job {job_id}")
await interaction.followup.send(content=f"Failed to remove reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} removed from the scheduler.")
# /remind pause
@discord.app_commands.command(name="pause", description="Pause a reminder")
async def pause(self, interaction: discord.Interaction, job_id: str) -> None:
"""Pause a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to pause.
"""
await interaction.response.defer()
logger.debug(f"Pausing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.pause_job(job_id)
logger.info(f"Paused job {job_id}.")
await interaction.followup.send(content=f"Reminder with ID {job_id} paused successfully.")
except JobLookupError as e:
logger.exception(f"Failed to pause job {job_id}")
await interaction.followup.send(content=f"Failed to pause reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} paused in the scheduler.")
# /remind unpause
@discord.app_commands.command(name="unpause", description="Unpause a reminder")
async def unpause(self, interaction: discord.Interaction, job_id: str) -> None:
"""Unpause a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to unpause.
"""
await interaction.response.defer()
logger.debug(f"Unpausing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.resume_job(job_id)
logger.info(f"Unpaused job {job_id}.")
await interaction.followup.send(content=f"Reminder with ID {job_id} unpaused successfully.")
except JobLookupError as e:
logger.exception(f"Failed to unpause job {job_id}")
await interaction.followup.send(content=f"Failed to unpause reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} unpaused in the scheduler.")
intents: discord.Intents = discord.Intents.default() intents: discord.Intents = discord.Intents.default()
intents.guild_scheduled_events = True intents.guild_scheduled_events = True
@@ -1001,7 +1247,7 @@ remind_group = RemindGroup()
bot.tree.add_command(remind_group) bot.tree.add_command(remind_group)
def send_webhook(custom_url: str = "", message: str = "") -> None: def send_webhook(*, custom_url: str, message: str) -> None:
"""Send a webhook to Discord. """Send a webhook to Discord.
Args: Args:
@@ -1032,20 +1278,63 @@ async def send_to_discord(channel_id: int, message: str, author_id: int) -> None
Args: Args:
channel_id: The Discord channel ID. channel_id: The Discord channel ID.
message: The message. message: The message.
author_id: User we should ping. author_id: User we should mention in the message.
"""
logger.info(f"Sending message to channel '{channel_id}' with message: '{message}'")
Raises:
RuntimeError: If the bot is not ready or is closed.
"""
logger.info(f"Sending message to channel '<#{channel_id}>' with message: '{message}'")
# Wait 3 seconds to ensure the bot is ready
logger.debug("Waiting for 3 seconds to ensure the bot is ready before sending the message.")
await asyncio.sleep(3)
# Early validation of bot state
if not bot.is_ready():
error_msg = f"Bot is not ready! Cannot send message to channel {channel_id}\nMessage: {message}\nAuthor ID: {author_id}"
logger.error(error_msg)
raise RuntimeError(error_msg)
if bot.is_closed():
error_msg = f"Bot is closed! Cannot send message to channel {channel_id}\nMessage: {message}\nAuthor ID: {author_id}"
logger.error(error_msg)
raise RuntimeError(error_msg)
# Debug bot state before attempting to fetch channel
_debug_bot_state()
try:
channel: GuildChannel | discord.Thread | PrivateChannel | None = bot.get_channel(channel_id) channel: GuildChannel | discord.Thread | PrivateChannel | None = bot.get_channel(channel_id)
logger.debug(f"bot.get_channel({channel_id}) returned: {channel}")
if channel is None: if channel is None:
logger.info(f"Channel {channel_id} not in cache, attempting to fetch from API")
channel = await bot.fetch_channel(channel_id) channel = await bot.fetch_channel(channel_id)
logger.debug(f"bot.fetch_channel({channel_id}) returned: {channel}")
except Exception as e:
logger.error(f"Failed to get/fetch channel {channel_id}: {type(e).__name__}: {e}")
logger.error(f"Bot state during error - is_ready: {bot.is_ready()}, is_closed: {bot.is_closed()}")
raise
# Channels we can't send messages to # Channels we can't send messages to
if isinstance(channel, discord.ForumChannel | discord.CategoryChannel | PrivateChannel): if isinstance(channel, discord.ForumChannel | discord.CategoryChannel | PrivateChannel):
logger.warning(f"We haven't implemented sending messages to this channel type {type(channel)}") logger.error(f"We haven't implemented sending messages to this channel type {type(channel)}")
return return
await channel.send(f"<@{author_id}>\n{message}") try:
logger.debug(f"Attempting to send message to channel {channel} (type: {type(channel)})")
message_content = f"<@{author_id}>\n{message}"
logger.debug(f"Message content length: {len(message_content)} characters")
sent_message = await channel.send(message_content)
logger.info(f"Successfully sent message to channel {channel_id}, message ID: {sent_message.id}")
except Exception as e:
logger.error(f"Failed to send message to channel {channel_id}: {type(e).__name__}: {e}")
logger.error(f"Channel: {channel}, Channel type: {type(channel)}")
logger.error(f"Bot state during send error - is_ready: {bot.is_ready()}, is_closed: {bot.is_closed()}")
if hasattr(channel, "guild"):
logger.error(f"Guild: {channel.guild}, Guild available: {getattr(channel.guild, 'available', 'Unknown')}")
raise
async def send_to_user(user_id: int, guild_id: int, message: str) -> None: async def send_to_user(user_id: int, guild_id: int, message: str) -> None:
@@ -1088,6 +1377,31 @@ async def send_to_user(user_id: int, guild_id: int, message: str) -> None:
logger.exception(f"Failed to send message '{message}' to user '{user_id}' in guild '{guild_id}'") logger.exception(f"Failed to send message '{message}' to user '{user_id}' in guild '{guild_id}'")
def _debug_bot_state() -> None:
"""Debug helper function to log bot state information."""
logger.debug(f"Bot is_ready: {bot.is_ready()}")
logger.debug(f"Bot is_closed: {bot.is_closed()}")
logger.debug(f"Bot user: {bot.user}")
logger.debug(f"Bot guilds count: {len(bot.guilds) if bot.guilds else 'None'}")
# Check bot's http client state
if hasattr(bot, "http") and bot.http:
logger.debug(f"Bot HTTP client state: connector_initialized={hasattr(bot.http, 'connector')}")
try:
# Safely check _global_over attribute which is causing the error
global_over = getattr(bot.http, "_global_over", None)
logger.debug(f"Bot HTTP _global_over type: {type(global_over)}")
logger.debug(f"Bot HTTP _global_over: {global_over}")
if global_over is not None and hasattr(global_over, "is_set"):
logger.debug(f"Bot HTTP _global_over.is_set(): {global_over.is_set()}")
else:
logger.warning("Bot HTTP _global_over missing is_set method - this is likely the cause of the error")
except (AttributeError, TypeError) as debug_error:
logger.warning(f"Could not inspect bot HTTP _global_over: {debug_error}")
else:
logger.error("Bot HTTP client is None or missing")
if __name__ == "__main__": if __name__ == "__main__":
bot_token: str = os.getenv("BOT_TOKEN", default="") bot_token: str = os.getenv("BOT_TOKEN", default="")
if not bot_token: if not bot_token:
@@ -1096,3 +1410,4 @@ if __name__ == "__main__":
logger.info("Starting bot.") logger.info("Starting bot.")
bot.run(bot_token) bot.run(bot_token)
logger.info("Bot has been stopped.")

View File

@@ -0,0 +1,405 @@
from __future__ import annotations
import traceback
from typing import TYPE_CHECKING
import discord
from discord.utils import escape_markdown
from loguru import logger
from discord_reminder_bot.helpers import calculate, parse_time
from discord_reminder_bot.settings import scheduler
if TYPE_CHECKING:
import datetime
from apscheduler.job import Job
class DateReminderModifyModal(discord.ui.Modal, title="Modify reminder"):
"""Modal for modifying a date-based APScheduler job (one-time reminder)."""
def __init__(self, job: Job) -> None:
"""Initialize the modal for modifying a date-based reminder.
Args:
job (Job): The APScheduler job to modify. Must be a date-based job.
"""
super().__init__(title="Modify Reminder")
self.job = job
self.job_id = job.id
self.message_input = discord.ui.TextInput(
label="Reminder message",
default=job.kwargs.get("message", ""),
placeholder="What do you want to be reminded of?",
max_length=200,
)
# Only allow editing the date/time for date-based reminders
self.time_input = discord.ui.TextInput(
label="New time",
placeholder="e.g. tomorrow at 3 PM",
required=True,
)
self.add_item(self.message_input)
self.add_item(self.time_input)
def _process_date_trigger(self, new_time_str: str, old_time: datetime.datetime | None) -> tuple[bool, str, Job | None]:
"""Process date trigger modification.
Args:
new_time_str (str): The new time string to parse.
old_time (datetime.datetime | None): The old scheduled time.
Returns:
tuple[bool, str, Job | None]: Success flag, error message, and rescheduled job.
"""
parsed_time: datetime.datetime | None = parse_time(new_time_str)
if not parsed_time:
return False, f"Invalid time format: `{new_time_str}`", None
if old_time and parsed_time == old_time:
return True, "", None # No change needed
logger.info(f"Rescheduling date-based job {self.job_id}")
try:
rescheduled_job = scheduler.reschedule_job(self.job_id, trigger="date", run_date=parsed_time)
except (ValueError, TypeError, AttributeError) as e:
logger.exception(f"Failed to reschedule date-based job: {e}")
return False, f"Failed to reschedule job: {e}", None
else:
return True, "", rescheduled_job
async def _update_message(self, old_message: str, new_message: str) -> bool:
"""Update the message of a job.
Args:
old_message (str): The old message.
new_message (str): The new message.
Returns:
bool: Whether the message was changed.
"""
if new_message == old_message:
return False
job: Job | None = scheduler.get_job(self.job_id)
if not job:
return False
old_kwargs = job.kwargs.copy()
scheduler.modify_job(
self.job_id,
kwargs={
**old_kwargs,
"message": new_message,
},
)
logger.debug(f"Modified job {self.job_id} with new message: {new_message}")
logger.debug(f"Old kwargs: {old_kwargs}, New kwargs: {job.kwargs}")
return True
async def on_submit(self, interaction: discord.Interaction) -> None:
"""Called when the modal is submitted for a date-based reminder.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
"""
old_message: str = self.job.kwargs.get("message", "")
old_time: datetime.datetime | None = self.job.next_run_time
old_time_countdown: str = calculate(self.job)
new_message: str = self.message_input.value
new_time_str: str = self.time_input.value
# Get the job to modify
job_to_modify: Job | None = scheduler.get_job(self.job_id)
if not job_to_modify:
await interaction.response.send_message(
f"Failed to get job.\n{new_message=}\n{new_time_str=}",
ephemeral=True,
)
return
# Defer early for long operations
await interaction.response.defer(ephemeral=True)
# Process date trigger
success, error_msg, rescheduled_job = self._process_date_trigger(new_time_str, old_time)
# If time input is invalid, send error message
if not success and error_msg:
await interaction.followup.send(error_msg, ephemeral=True)
return
# Update the message if changed
msg: str = f"Modified job `{escape_markdown(self.job_id)}`:\n"
changes_made = False
# Add schedule change info to message
if rescheduled_job:
if old_time:
msg += (
f"Old time: `{old_time.strftime('%Y-%m-%d %H:%M:%S')}` (In {old_time_countdown})\n"
f"New time: Next run in {calculate(rescheduled_job)}\n"
)
else:
msg += f"Job unpaused. Next run in {calculate(rescheduled_job)}\n"
changes_made = True
# Update message if changed
message_changed: bool = await self._update_message(old_message, new_message)
if message_changed:
msg += f"Old message: `{escape_markdown(old_message)}`\n"
msg += f"New message: `{escape_markdown(new_message)}`.\n"
changes_made = True
# Send confirmation message
if changes_made:
await interaction.followup.send(content=msg)
else:
await interaction.followup.send(content=f"No changes made to job `{escape_markdown(self.job_id)}`.", ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
"""A callback that is called when on_submit fails with an error.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
error (Exception): The raised exception.
"""
# Check if the interaction has already been responded to
if not interaction.response.is_done():
await interaction.response.send_message("Oops! Something went wrong.", ephemeral=True)
else:
try:
await interaction.followup.send("Oops! Something went wrong.", ephemeral=True)
except discord.HTTPException:
logger.warning("Failed to send error message via followup")
logger.exception(f"Error in {self.__class__.__name__}: {error}")
traceback.print_exception(type(error), error, error.__traceback__)
class CronReminderModifyModal(discord.ui.Modal, title="Modify reminder"):
"""A modal for modifying a cron-based reminder."""
def __init__(self, job: Job) -> None:
"""Initialize the modal for modifying a date-based reminder.
Args:
job (Job): The APScheduler job to modify. Must be a date-based job.
"""
super().__init__(title="Modify Reminder")
self.job = job
self.job_id = job.id
# message
self.message_input = discord.ui.TextInput(
label="Reminder message",
default=job.kwargs.get("message", ""),
placeholder="What do you want to be reminded of?",
max_length=200,
)
async def _update_message(self, old_message: str, new_message: str) -> bool:
"""Update the message of a job.
Args:
old_message (str): The old message.
new_message (str): The new message.
Returns:
bool: Whether the message was changed.
"""
if new_message == old_message:
return False
job: Job | None = scheduler.get_job(self.job_id)
if not job:
return False
old_kwargs = job.kwargs.copy()
scheduler.modify_job(
self.job_id,
kwargs={
**old_kwargs,
"message": new_message,
},
)
logger.debug(f"Modified job {self.job_id} with new message: {new_message}")
logger.debug(f"Old kwargs: {old_kwargs}, New kwargs: {job.kwargs}")
return True
async def on_submit(self, interaction: discord.Interaction) -> None:
"""Called when the modal is submitted for a cron-based reminder.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
"""
old_message: str = self.job.kwargs.get("message", "")
new_message: str = self.message_input.value
# Get the job to modify
job_to_modify: Job | None = scheduler.get_job(self.job_id)
if not job_to_modify:
await interaction.response.send_message(
f"Failed to get job.\n{new_message=}",
ephemeral=True,
)
return
# Defer early for long operations
await interaction.response.defer(ephemeral=True)
# Update the message if changed
msg: str = f"Modified job `{escape_markdown(self.job_id)}`:\n"
changes_made = False
# Update message if changed
message_changed: bool = await self._update_message(old_message, new_message)
if message_changed:
msg += f"Old message: `{escape_markdown(old_message)}`\n"
msg += f"New message: `{escape_markdown(new_message)}`.\n"
changes_made = True
# Send confirmation message
if changes_made:
await interaction.followup.send(content=msg)
else:
await interaction.followup.send(content=f"No changes made to job `{escape_markdown(self.job_id)}`.", ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
"""A callback that is called when on_submit fails with an error.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
error (Exception): The raised exception.
"""
# Check if the interaction has already been responded to
if not interaction.response.is_done():
await interaction.response.send_message("Oops! Something went wrong.", ephemeral=True)
else:
try:
await interaction.followup.send("Oops! Something went wrong.", ephemeral=True)
except discord.HTTPException:
logger.warning("Failed to send error message via followup")
logger.exception(f"Error in {self.__class__.__name__}: {error}")
traceback.print_exception(type(error), error, error.__traceback__)
class IntervalReminderModifyModal(discord.ui.Modal, title="Modify reminder"):
"""A modal for modifying an interval-based reminder."""
def __init__(self, job: Job) -> None:
"""Initialize the modal for modifying a date-based reminder.
Args:
job (Job): The APScheduler job to modify. Must be a date-based job.
"""
super().__init__(title="Modify Reminder")
self.job = job
self.job_id = job.id
# message
self.message_input = discord.ui.TextInput(
label="Reminder message",
default=job.kwargs.get("message", ""),
placeholder="What do you want to be reminded of?",
max_length=200,
)
self.add_item(self.message_input)
async def _update_message(self, old_message: str, new_message: str) -> bool:
"""Update the message of a job.
Args:
old_message (str): The old message.
new_message (str): The new message.
Returns:
bool: Whether the message was changed.
"""
if new_message == old_message:
return False
job: Job | None = scheduler.get_job(self.job_id)
if not job:
return False
old_kwargs = job.kwargs.copy()
scheduler.modify_job(
self.job_id,
kwargs={
**old_kwargs,
"message": new_message,
},
)
logger.debug(f"Modified job {self.job_id} with new message: {new_message}")
logger.debug(f"Old kwargs: {old_kwargs}, New kwargs: {job.kwargs}")
return True
async def on_submit(self, interaction: discord.Interaction) -> None:
"""Called when the modal is submitted for an interval-based reminder.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
"""
old_message: str = self.job.kwargs.get("message", "")
new_message: str = self.message_input.value
# Get the job to modify
job_to_modify: Job | None = scheduler.get_job(self.job_id)
if not job_to_modify:
await interaction.response.send_message(
f"Failed to get job.\n{new_message=}",
ephemeral=True,
)
return
# Defer early for long operations
await interaction.response.defer(ephemeral=True)
# Update the message if changed
msg: str = f"Modified job `{escape_markdown(self.job_id)}`:\n"
changes_made = False
# Update message if changed
message_changed: bool = await self._update_message(old_message, new_message)
if message_changed:
msg += f"Old message: `{escape_markdown(old_message)}`\n"
msg += f"New message: `{escape_markdown(new_message)}`.\n"
changes_made = True
# Send confirmation message
if changes_made:
await interaction.followup.send(content=msg)
else:
await interaction.followup.send(content=f"No changes made to job `{escape_markdown(self.job_id)}`.", ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
"""A callback that is called when on_submit fails with an error.
Args:
interaction (discord.Interaction): The Discord interaction where this modal was triggered from.
error (Exception): The raised exception.
"""
# Check if the interaction has already been responded to
if not interaction.response.is_done():
await interaction.response.send_message("Oops! Something went wrong.", ephemeral=True)
else:
try:
await interaction.followup.send("Oops! Something went wrong.", ephemeral=True)
except discord.HTTPException:
logger.warning("Failed to send error message via followup")
logger.exception(f"Error in {self.__class__.__name__}: {error}")
traceback.print_exception(type(error), error, error.__traceback__)

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import os
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv
from loguru import logger
from discord_reminder_bot.helpers import generate_state
load_dotenv(verbose=True)
def get_scheduler() -> AsyncIOScheduler:
"""Return the scheduler instance.
Uses the SQLITE_LOCATION environment variable for the SQLite database location.
Raises:
ValueError: If the timezone is missing or invalid.
Returns:
AsyncIOScheduler: The scheduler instance.
"""
config_timezone: str | None = os.getenv("TIMEZONE")
if not config_timezone:
msg = "Missing timezone. Please set the TIMEZONE environment variable."
raise ValueError(msg)
# Test if the timezone is valid
try:
ZoneInfo(config_timezone)
except (ZoneInfoNotFoundError, ModuleNotFoundError) as e:
msg: str = f"Invalid timezone: {config_timezone}. Error: {e}"
raise ValueError(msg) from e
logger.info(f"Using timezone: {config_timezone}. If this is incorrect, please set the TIMEZONE environment variable.")
sqlite_location: str = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite")
logger.info(f"Using SQLite database at: {sqlite_location}")
jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")}
job_defaults: dict[str, bool] = {"coalesce": True}
timezone = pytz.timezone(config_timezone)
return AsyncIOScheduler(jobstores=jobstores, timezone=timezone, job_defaults=job_defaults)
scheduler: AsyncIOScheduler = get_scheduler()
def export_reminder_jobs_to_markdown() -> None:
"""Loop through the APScheduler database and save each job's data to a markdown file if changed."""
data_dir: str = os.getenv("DATA_DIR", default="./data")
logger.info(f"Exporting reminder jobs to markdown files in directory: {data_dir}")
for job in scheduler.get_jobs():
job_state: str = generate_state(job.__getstate__(), job)
file_path: Path = Path(data_dir) / "reminder_data" / f"{job.id}.md"
file_path.parent.mkdir(parents=True, exist_ok=True)
try:
if file_path.exists():
existing_content = file_path.read_text(encoding="utf-8")
if existing_content == job_state:
logger.debug(f"No changes for {file_path}, skipping write.")
continue
file_path.write_text(job_state, encoding="utf-8")
logger.info(f"Data saved to {file_path}")
except OSError as e:
logger.error(f"Failed to save data to {file_path}: {e}")
def get_markdown_contents_from_markdown_file(job_id: str) -> str:
"""Get the contents of a markdown file for a specific job ID.
Args:
job_id (str): The ID of the job.
Returns:
str: The contents of the markdown file, or an empty string if the file does not exist.
"""
data_dir: str = os.getenv("DATA_DIR", default="./data")
file_path: Path = Path(data_dir) / "reminder_data" / f"{job_id}.md"
if file_path.exists():
return file_path.read_text(encoding="utf-8")
return ""

View File

@@ -1,14 +1,13 @@
services: services:
discord-reminder-bot: discord-reminder-bot:
image: thelovinator/discord-reminder-bot image: ghcr.io/thelovinator1/discord-reminder-bot:latest
env_file: env_file:
- .env - .env
container_name: discord-reminder-bot container_name: discord-reminder-bot
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- TIMEZONE=${TIMEZONE} - TIMEZONE=${TIMEZONE}
- LOG_LEVEL=${LOG_LEVEL} - WEBHOOK_URL=${WEBHOOK_URL}
- SQLITE_LOCATION=/data/jobs.sqlite
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- data_folder:/home/botuser/data/ - data_folder:/home/botuser/data/

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
import nox # type: ignore[import]
nox.options.default_venv_backend = "uv"
@nox.session(python=["3.10", "3.11", "3.12", "3.13"])
def tests(session: nox.Session) -> None:
"""Run the test suite."""
session.install(".")
session.install("pytest")
session.run("pytest")

View File

@@ -1,91 +1,23 @@
[project] [project]
name = "discord-reminder-bot" name = "discord-reminder-bot"
version = "2.0.0" version = "3.0.0"
description = "Discord bot that allows you to set date, cron and interval reminders." description = "Discord bot that allows you to set date, cron and interval reminders."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.13"
dependencies = [ dependencies = [
# The Discord bot library uses discord.py "apscheduler",
"discord-py[speed]>=2.5.0", # https://github.com/Rapptz/discord.py "dateparser",
"discord-py[speed]",
# For parsing dates and times in /remind commands "discord-webhook",
"dateparser>=1.0.0", # https://github.com/scrapinghub/dateparser "loguru",
"python-dotenv",
# For sending webhook messages to Discord "sentry-sdk",
"discord-webhook>=1.3.1", # https://github.com/lovvskillz/python-discord-webhook "sqlalchemy",
# For scheduling reminders, sqlalchemy is needed for storing reminders in a database
"apscheduler>=3.11.0", # https://github.com/agronholm/apscheduler
"sqlalchemy>=2.0.37", # https://github.com/sqlalchemy/sqlalchemy
# For loading environment variables from a .env file
"python-dotenv>=1.0.1", # https://github.com/theskumar/python-dotenv
# For error tracking
"sentry-sdk>=2.20.0", # https://github.com/getsentry/sentry-python
# For logging
"loguru>=0.7.3", # https://github.com/Delgan/loguru
] ]
[dependency-groups] [dependency-groups]
dev = ["pytest"] dev = ["pytest"]
[tool.poetry]
name = "discord-reminder-bot"
version = "2.0.0"
description = "Discord bot that allows you to set date, cron and interval reminders."
authors = ["Joakim Hellsén <tlovinator@gmail.com>"]
license = "GPL-3.0-or-later"
[tool.poetry.scripts]
bot = "discord_reminder_bot.main:start"
[tool.poetry.dependencies]
python = "^3.10"
# https://github.com/agronholm/apscheduler
# https://github.com/sqlalchemy/sqlalchemy
# For scheduling reminders, sqlalchemy is needed for storing reminders in a database
sqlalchemy = {version = ">=2.0.37,<3.0.0"}
apscheduler = {version = ">=3.11.0,<4.0.0"}
# https://github.com/scrapinghub/dateparser
# For parsing dates and times in /remind commands
dateparser = {version = ">=1.0.0"}
# https://github.com/Rapptz/discord.py
# https://github.com/jackrosenthal/legacy-cgi
# https://github.com/AbstractUmbra/audioop
# The Discord bot library uses discord.py
# legacy-cgi and audioop-lts are because Python 3.13 removed cgi module and audioop module
discord-py = {version = ">=2.4.0,<3.0.0", extras = ["speed"]}
legacy-cgi = {version = ">=2.6.2,<3.0.0", markers = "python_version >= '3.13'"}
audioop-lts = {version = ">=0.2.1,<1.0.0", markers = "python_version >= '3.13'"}
# https://github.com/lovvskillz/python-discord-webhook
# For sending webhook messages to Discord
discord-webhook = {version = ">=1.3.1,<2.0.0"}
# https://github.com/theskumar/python-dotenv
# For loading environment variables from a .env file
python-dotenv = {version = ">=1.0.1,<2.0.0"}
# https://github.com/getsentry/sentry-python
# For error tracking
sentry-sdk = {version = ">=2.20.0,<3.0.0"}
# https://github.com/Delgan/loguru
# For logging
loguru = {version = ">=0.7.3,<1.0.0"}
[tool.poetry.dev-dependencies]
pytest = "*"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.ruff] [tool.ruff]
preview = true preview = true
line-length = 140 line-length = 140
@@ -128,10 +60,6 @@ lint.ignore = [
"W191", # Checks for indentation that uses tabs. "W191", # Checks for indentation that uses tabs.
] ]
[tool.ruff.format]
docstring-code-format = true
docstring-code-line-length = 20
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/test_*.py" = [ "**/test_*.py" = [
"ARG", # Unused function args -> fixtures nevertheless are functionally relevant... "ARG", # Unused function args -> fixtures nevertheless are functionally relevant...

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import zoneinfo import zoneinfo
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import pytest import pytest
@@ -12,7 +12,7 @@ from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from discord_reminder_bot import main from discord_reminder_bot import main
from discord_reminder_bot.main import calculate, parse_time from discord_reminder_bot.helpers import calculate, parse_time
if TYPE_CHECKING: if TYPE_CHECKING:
from apscheduler.job import Job from apscheduler.job import Job
@@ -25,7 +25,7 @@ def dummy_job() -> None:
def test_calculate() -> None: def test_calculate() -> None:
"""Test the calculate function with various job inputs.""" """Test the calculate function with various job inputs."""
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.timezone = timezone.utc scheduler.timezone = UTC
scheduler.start() scheduler.start()
# Create a job with a DateTrigger # Create a job with a DateTrigger