Compare commits

...

10 Commits

Author SHA1 Message Date
5ec31ba126 Add Nox to development dependencies and format dependency entries
Some checks failed
Docker / build-and-push-docker (push) Failing after 1m27s
Poetry / test-on-poetry (latest, 3.10) (push) Failing after 24s
Poetry / test-on-poetry (latest, 3.11) (push) Failing after 3s
Poetry / test-on-poetry (latest, 3.12) (push) Failing after 3s
Poetry / test-on-poetry (latest, 3.13) (push) Failing after 3s
Poetry / test-on-poetry (main, 3.10) (push) Failing after 3s
Poetry / test-on-poetry (main, 3.11) (push) Failing after 2s
Poetry / test-on-poetry (main, 3.12) (push) Failing after 3s
Poetry / test-on-poetry (main, 3.13) (push) Failing after 3s
uv / Install with uv and run tests on Python 3.10 (push) Failing after 3s
uv / Install with uv and run tests on Python 3.11 (push) Failing after 3s
uv / Install with uv and run tests on Python 3.12 (push) Failing after 4s
uv / Install with uv and run tests on Python 3.13 (push) Failing after 3s
uv / Install with uv and run tests on Python pypy (push) Failing after 5s
2025-04-12 11:35:50 +02:00
52d8501ef2 Remove SECURITY.md 2025-04-12 11:34:53 +02:00
12c5ece487 Add pause and unpause commands for reminders 2025-04-12 10:59:29 +02:00
7b192fc425 Ignore SQLite database backup files 2025-04-11 21:32:32 +02:00
d55f1993e8 Add remove command for reminders 2025-04-08 17:57:50 +02:00
3e5e23591d Update Copilot instructions 2025-03-04 23:10:24 +01:00
0a2fd88cc0 Make Copilot Instructions shorter 2025-03-02 17:46:47 +01:00
e32d149722 Refactor GitHub Copilot instructions 2025-03-02 05:21:28 +01:00
d583154857 Add GitHub Copilot instructions 2025-03-02 05:17:39 +01:00
36dcf8d376 Add get_human_readable_time function and update reminder summary formatting 2025-03-02 04:37:42 +01:00
5 changed files with 212 additions and 29 deletions

9
.github/SECURITY.md vendored
View File

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

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

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

1
.gitignore vendored
View File

@ -172,3 +172,4 @@ cython_debug/
# SQLite
*.sqlite
*.sqlite.*

View File

@ -17,6 +17,7 @@ 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
@ -27,8 +28,9 @@ from discord_webhook import DiscordWebhook
from dotenv import load_dotenv
from loguru import logger
from interactions.api.models.misc import Snowflake
if TYPE_CHECKING:
from apscheduler.job import Job
from discord.guild import GuildChannel
from discord.interactions import InteractionChannel
from discord.types.channel import _BaseChannel
@ -46,6 +48,42 @@ sentry_sdk.init(
)
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.
@ -114,6 +152,33 @@ def calculate(job: Job) -> str:
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.
@ -271,22 +336,22 @@ def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: P
guild = ctx.channel.guild
channels: list[GuildChannel] | list[_BaseChannel] = list(guild.channels) if guild else []
list_of_channels_in_current_guild: list[int] = [c.id for c in channels]
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 0
guild_id: int = guild.id if guild else -1
guild_id_from_kwargs: int | None = job.kwargs.get("guild_id")
channel_id_from_kwargs: int | None = job.kwargs.get("channel_id")
guild_id_from_kwargs = int(job.kwargs.get("guild_id", 0))
if guild_id_from_kwargs != guild_id:
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
if channel_id_from_kwargs not in list_of_channels_in_current_guild:
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)
if len(jobs) != len(jobs_in_guild):
@ -314,13 +379,19 @@ def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: P
job_msg: str = "```md\n"
job_msg += f"# {job.kwargs.get('message', '')}\n"
job_msg += f" * {job.id}\n"
job_msg += f" * {job.trigger} {calculate(job)}"
job_msg += f" * {job.trigger} {get_human_readable_time(job)}"
if job.kwargs.get("user_id"):
job_msg += f" <@{job.kwargs.get('user_id')}>"
if job.kwargs.get("channel_id"):
job_msg += f" <#{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}"
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_msg += "```"
@ -990,6 +1061,92 @@ class RemindGroup(discord.app_commands.Group):
else:
await interaction.followup.send(content="No new reminders were added.")
# /remind remove
@discord.app_commands.command(name="remove", description="Remove a reminder")
async def remove(self, interaction: discord.Interaction, job_id: str) -> None:
"""Remove a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to remove.
"""
await interaction.response.defer()
logger.debug(f"Removing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.remove_job(job_id)
logger.info(f"Removed job {job_id}. {job.__getstate__()}")
await interaction.followup.send(
content=f"Reminder with ID {job_id} removed successfully.\n{generate_markdown_state(job.__getstate__())}",
)
except JobLookupError as e:
logger.exception(f"Failed to remove job {job_id}")
await interaction.followup.send(content=f"Failed to remove reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} removed from the scheduler.")
# /remind pause
@discord.app_commands.command(name="pause", description="Pause a reminder")
async def pause(self, interaction: discord.Interaction, job_id: str) -> None:
"""Pause a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to pause.
"""
await interaction.response.defer()
logger.debug(f"Pausing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.pause_job(job_id)
logger.info(f"Paused job {job_id}.")
await interaction.followup.send(content=f"Reminder with ID {job_id} paused successfully.")
except JobLookupError as e:
logger.exception(f"Failed to pause job {job_id}")
await interaction.followup.send(content=f"Failed to pause reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} paused in the scheduler.")
# /remind unpause
@discord.app_commands.command(name="unpause", description="Unpause a reminder")
async def unpause(self, interaction: discord.Interaction, job_id: str) -> None:
"""Unpause a scheduled reminder.
Args:
interaction (discord.Interaction): The interaction object for the command.
job_id (str): The identifier of the job to unpause.
"""
await interaction.response.defer()
logger.debug(f"Unpausing reminder with ID {job_id} for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.debug(f"Arguments: {locals()}")
try:
job: Job | None = scheduler.get_job(job_id)
if not job:
await interaction.followup.send(content=f"Reminder with ID {job_id} not found.", ephemeral=True)
return
scheduler.resume_job(job_id)
logger.info(f"Unpaused job {job_id}.")
await interaction.followup.send(content=f"Reminder with ID {job_id} unpaused successfully.")
except JobLookupError as e:
logger.exception(f"Failed to unpause job {job_id}")
await interaction.followup.send(content=f"Failed to unpause reminder with ID {job_id}. {e}", ephemeral=True)
logger.info(f"Job {job_id} unpaused in the scheduler.")
intents: discord.Intents = discord.Intents.default()
intents.guild_scheduled_events = True

View File

@ -29,7 +29,7 @@ dependencies = [
]
[dependency-groups]
dev = ["pytest"]
dev = ["pytest", "nox"]
[tool.poetry]
name = "discord-reminder-bot"
@ -47,40 +47,41 @@ 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"}
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"}
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'"}
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"}
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"}
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"}
sentry-sdk = { version = ">=2.20.0,<3.0.0" }
# https://github.com/Delgan/loguru
# For logging
loguru = {version = ">=0.7.3,<1.0.0"}
loguru = { version = ">=0.7.3,<1.0.0" }
[tool.poetry.dev-dependencies]
pytest = "*"
nox = "*"
[build-system]
requires = ["poetry-core>=1.0.0"]