mirror of
https://github.com/TheLovinator1/discord-reminder-bot.git
synced 2025-10-19 21:39:48 +02:00
Compare commits
122 Commits
d5c79e8ad7
...
renovate/p
Author | SHA1 | Date | |
---|---|---|---|
|
4169d88acb | ||
|
e0ecb56b14 | ||
069760fb5e | |||
|
b07d5a8dc5 | ||
3691a2d812 | |||
7c169f4329 | |||
9c2a2e66c5 | |||
|
0bb55726fe | ||
|
b678e7784a | ||
|
90a9a17a63 | ||
|
d16da0de6f | ||
|
d051b96069 | ||
|
49dd097948 | ||
|
992e3ae3c4 | ||
|
ee94587a0b | ||
|
03c76f1154 | ||
|
cf11b1b5a5 | ||
|
2341932af5 | ||
|
02052d5e2a | ||
|
ec17bc35d9 | ||
|
431c9c1a27 | ||
|
35c45fc230 | ||
|
17d2b6956b | ||
|
0e91dcfb82 | ||
|
3d6680b4bd | ||
|
81c34a3446 | ||
|
8565243c4c | ||
|
8ecbce4d12 | ||
|
6208da86c0 | ||
|
b8bdf824a6 | ||
|
cd10e20cab | ||
|
bfb2b0b81e | ||
|
7f84da5b9c | ||
|
79f16a44a3 | ||
|
18008be8f3 | ||
|
962c369b88 | ||
|
87ed08d250 | ||
|
37ddfd24c3 | ||
|
3c0638ba47 | ||
|
5dbac43b27 | ||
|
3a268c0cb5 | ||
|
d8d57a6581 | ||
|
35b1874714 | ||
|
1fc3482c36 | ||
|
dd7e51fc3f | ||
|
ea4f3882f9 | ||
|
c89faf85db | ||
|
364f1b8aea | ||
|
ffaae102c5 | ||
|
3f489edb37 | ||
|
ca29c06c81 | ||
|
dd9dab3f8b | ||
|
a0a7519f14 | ||
|
4ef58a394b | ||
|
d2ffd0c9c5 | ||
|
e81db87cd4 | ||
|
c11714fe8c | ||
|
d46b648134 | ||
|
747b031eca | ||
|
ae0a83c1f0 | ||
|
d799845ba7 | ||
|
84ec0e673b | ||
|
963c8f4e3a | ||
|
28bf3679de | ||
|
cb8213aa74 | ||
|
c5de3bfe71 | ||
|
7025fafb72 | ||
|
453a44f125 | ||
|
1cdbcf1d96 | ||
|
e930a3bd9b | ||
|
3a340d622f | ||
c665ea9ea9 | |||
c8d7e059b2 | |||
a99c381bec | |||
bb752cd6cc | |||
c3161c2e0a | |||
ca3c3bbb1b | |||
|
4f08d4b73f | ||
|
478138242d | ||
|
eaa231bf5a | ||
|
e1910d6cf8 | ||
4f5d62b30d | |||
|
6edc70d6e1 | ||
|
6f9a2cc995 | ||
7c39f5fdc8 | |||
8237b3ac46 | |||
|
2ff50ba2cd | ||
09cccd51cc | |||
c7c761a8cd | |||
e58dab8163 | |||
1508147399 | |||
2f6f74c82f | |||
59edb2a41f | |||
fe559c41a9 | |||
d7ea1c9ec4 | |||
9f2814a3d5 | |||
4dde52ec04 | |||
c6d8df3d80 | |||
77575b0934 | |||
e5c662ba20 | |||
b97e8260ae | |||
993a8fd6d9 | |||
480b36ad85 | |||
679fedb099 | |||
12f705418a | |||
865cd9ba6d | |||
1cf10fc7a9 | |||
9299ab800d | |||
acf742c91c | |||
fd826da6e4 | |||
b87639910b | |||
41c03e10f6
|
|||
5ec31ba126
|
|||
52d8501ef2
|
|||
12c5ece487
|
|||
7b192fc425
|
|||
d55f1993e8
|
|||
3e5e23591d
|
|||
0a2fd88cc0
|
|||
e32d149722
|
|||
d583154857
|
|||
36dcf8d376
|
@@ -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
9
.github/SECURITY.md
vendored
@@ -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
33
.github/copilot-instructions.md
vendored
Normal 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>"
|
35
.github/workflows/docker-publish.yml
vendored
35
.github/workflows/docker-publish.yml
vendored
@@ -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: .
|
||||||
|
38
.github/workflows/poetry.yml
vendored
38
.github/workflows/poetry.yml
vendored
@@ -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
|
|
41
.github/workflows/uv.yml
vendored
41
.github/workflows/uv.yml
vendored
@@ -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
52
.gitignore
vendored
@@ -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/*
|
||||||
|
@@ -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
3
.vscode/launch.json
vendored
@@ -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",
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -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"
|
||||||
],
|
],
|
||||||
|
15
Dockerfile
15
Dockerfile
@@ -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"]
|
||||||
|
16
README.md
16
README.md
@@ -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.
|
||||||
|
160
discord_reminder_bot/helpers.py
Normal file
160
discord_reminder_bot/helpers.py
Normal 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```"
|
File diff suppressed because it is too large
Load Diff
405
discord_reminder_bot/modals.py
Normal file
405
discord_reminder_bot/modals.py
Normal 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__)
|
89
discord_reminder_bot/settings.py
Normal file
89
discord_reminder_bot/settings.py
Normal 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 ""
|
@@ -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/
|
||||||
|
13
noxfile.py
13
noxfile.py
@@ -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")
|
|
@@ -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...
|
||||||
|
@@ -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
|
||||||
|
Reference in New Issue
Block a user