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.
|
||||
# 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=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:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
- cron: '0 16 * * 0'
|
||||
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
@@ -20,38 +20,41 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
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
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
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
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64, linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: thelovinator/discord-reminder-bot:latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
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
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
@@ -46,7 +46,7 @@ htmlcov/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
@@ -106,17 +106,24 @@ uv.lock
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
pdm.lock
|
||||
pdm.toml
|
||||
.pdm-python
|
||||
.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
|
||||
__pypackages__/
|
||||
|
||||
@@ -129,6 +136,7 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -167,8 +175,38 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.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
|
||||
.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.*
|
||||
data/reminder_data/*
|
||||
|
@@ -1,11 +1,11 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v3.1.0
|
||||
rev: v3.2.0
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-ast
|
||||
@@ -23,13 +23,13 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.19.1
|
||||
rev: v3.20.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--py310-plus"]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.6
|
||||
rev: v0.13.3
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
- id: ruff
|
||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -5,8 +5,7 @@
|
||||
"name": "Python Debugger: Start bot",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/discord_reminder_bot/main.py",
|
||||
"console": "integratedTerminal",
|
||||
"module": "discord_reminder_bot.main"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"aiohttp",
|
||||
"ambiguious",
|
||||
"apscheduler",
|
||||
"asctime",
|
||||
"asyncio",
|
||||
@@ -24,14 +25,17 @@
|
||||
"levelname",
|
||||
"loguru",
|
||||
"Lovinator",
|
||||
"McCabe",
|
||||
"pycodestyle",
|
||||
"pydocstyle",
|
||||
"pyproject",
|
||||
"pypy",
|
||||
"pytest",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"PYTHONUNBUFFERED",
|
||||
"pyupgrade",
|
||||
"sqlalchemy",
|
||||
"strptime",
|
||||
"thelovinator",
|
||||
"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 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN useradd -m botuser && mkdir -p /home/botuser/data
|
||||
WORKDIR /home/botuser
|
||||
|
||||
COPY interactions /home/botuser/interactions
|
||||
COPY discord_reminder_bot /home/botuser/discord_reminder_bot
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --no-install-project
|
||||
|
||||
ENV DATA_DIR=/home/botuser/data
|
||||
ENV SQLITE_LOCATION=/data/jobs.sqlite
|
||||
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 the latest version of needed software:
|
||||
- [Python](https://www.python.org/)
|
||||
- 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.
|
||||
- `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
||||
- Download project from GitHub with Git or download
|
||||
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.
|
||||
- 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:
|
||||
- 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.
|
||||
- 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
|
||||
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:
|
||||
- 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>.
|
||||
|
||||
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:
|
||||
discord-reminder-bot:
|
||||
image: thelovinator/discord-reminder-bot
|
||||
image: ghcr.io/thelovinator1/discord-reminder-bot:latest
|
||||
env_file:
|
||||
- .env
|
||||
container_name: discord-reminder-bot
|
||||
environment:
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- TIMEZONE=${TIMEZONE}
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
- SQLITE_LOCATION=/data/jobs.sqlite
|
||||
- WEBHOOK_URL=${WEBHOOK_URL}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- 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]
|
||||
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."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
# The Discord bot library uses discord.py
|
||||
"discord-py[speed]>=2.5.0", # https://github.com/Rapptz/discord.py
|
||||
|
||||
# For parsing dates and times in /remind commands
|
||||
"dateparser>=1.0.0", # https://github.com/scrapinghub/dateparser
|
||||
|
||||
# For sending webhook messages to Discord
|
||||
"discord-webhook>=1.3.1", # https://github.com/lovvskillz/python-discord-webhook
|
||||
|
||||
# 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
|
||||
"apscheduler",
|
||||
"dateparser",
|
||||
"discord-py[speed]",
|
||||
"discord-webhook",
|
||||
"loguru",
|
||||
"python-dotenv",
|
||||
"sentry-sdk",
|
||||
"sqlalchemy",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
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]
|
||||
preview = true
|
||||
line-length = 140
|
||||
@@ -128,10 +60,6 @@ lint.ignore = [
|
||||
"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]
|
||||
"**/test_*.py" = [
|
||||
"ARG", # Unused function args -> fixtures nevertheless are functionally relevant...
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zoneinfo
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +12,7 @@ from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
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:
|
||||
from apscheduler.job import Job
|
||||
@@ -25,7 +25,7 @@ def dummy_job() -> None:
|
||||
def test_calculate() -> None:
|
||||
"""Test the calculate function with various job inputs."""
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.timezone = timezone.utc
|
||||
scheduler.timezone = UTC
|
||||
scheduler.start()
|
||||
|
||||
# Create a job with a DateTrigger
|
||||
|
Reference in New Issue
Block a user