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