Compare commits
10 Commits
d5c79e8ad7
...
5ec31ba126
Author | SHA1 | Date | |
---|---|---|---|
5ec31ba126
|
|||
52d8501ef2
|
|||
12c5ece487
|
|||
7b192fc425
|
|||
d55f1993e8
|
|||
3e5e23591d
|
|||
0a2fd88cc0
|
|||
e32d149722
|
|||
d583154857
|
|||
36dcf8d376
|
9
.github/SECURITY.md
vendored
9
.github/SECURITY.md
vendored
@ -1,9 +0,0 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
You can report a vulnerability by creating an issue on this repo. If you want to report it privately, you can email me at [tlovinator@gmail.com](mailto:tlovinator@gmail.com).
|
||||
|
||||
There is also [GitHub Security Advisories](https://github.com/TheLovinator1/discord-reminder-bot/security/advisories/new) if you want to be try-hard.
|
||||
|
||||
I am also available on Discord at `TheLovinator#9276`.
|
||||
|
||||
Thanks :-)
|
33
.github/copilot-instructions.md
vendored
Normal file
33
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
This is a Discord.py bot that allows you to set date, cron and interval reminders with APScheduler. Dates are parsed using dateparser.
|
||||
|
||||
Use try-except blocks, type hints, f-strings, logging, and Google style docstrings.
|
||||
|
||||
Add helpful message when using assert in tests.
|
||||
|
||||
Docstrings that doesn't return anything should not have a return section.
|
||||
|
||||
A function docstring should describe the function's behavior, arguments, side effects, exceptions, return values, and any other information that may be relevant to the user.
|
||||
|
||||
Including the exception object in the log message is redundant.
|
||||
|
||||
We use GitHub.
|
||||
|
||||
Channel reminders have the following kwargs: "channel_id", "message", "author_id".
|
||||
|
||||
User DM reminders have the following kwargs: "user_id", "guild_id", "message".
|
||||
|
||||
Bot has the following commands:
|
||||
|
||||
"/remind add message:<str> time:<str> dm_and_current_channel:<bool> user:<user> channel:<channel>"
|
||||
|
||||
"/remind remove id:<job_id>"
|
||||
|
||||
"/remind edit id:<job_id>"
|
||||
|
||||
"/remind pause_unpause id:<job_id>"
|
||||
|
||||
"/remind list"
|
||||
|
||||
"/remind cron message:<str> year:<int> month:<int> day:<int> week:<int> day_of_week:<str> hour:<int> minute:<int> second:<int> start_date:<str> end_date:<str> timezone:<str> jitter:<int> channel:<channel> user:<user> dm_and_current_channel:<bool>"
|
||||
|
||||
"/remind interval message:<str> weeks:<int> days:<int> hours:<int> minutes:<int> seconds:<int> start_date:<str> end_date:<str> timezone:<str> jitter:<int> channel:<channel> user:<user> dm_and_current_channel:<bool>"
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -172,3 +172,4 @@ cython_debug/
|
||||
|
||||
# SQLite
|
||||
*.sqlite
|
||||
*.sqlite.*
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
Reference in New Issue
Block a user