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
|
*.sqlite
|
||||||
|
*.sqlite.*
|
||||||
|
@ -17,6 +17,7 @@ import sentry_sdk
|
|||||||
from apscheduler import events
|
from apscheduler import events
|
||||||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, JobExecutionEvent
|
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, JobExecutionEvent
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
from apscheduler.jobstores.base import JobLookupError
|
||||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
@ -27,8 +28,9 @@ from discord_webhook import DiscordWebhook
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from interactions.api.models.misc import Snowflake
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from apscheduler.job import Job
|
|
||||||
from discord.guild import GuildChannel
|
from discord.guild import GuildChannel
|
||||||
from discord.interactions import InteractionChannel
|
from discord.interactions import InteractionChannel
|
||||||
from discord.types.channel import _BaseChannel
|
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:
|
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.
|
"""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>"
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def get_scheduler() -> AsyncIOScheduler:
|
def get_scheduler() -> AsyncIOScheduler:
|
||||||
"""Return the scheduler instance.
|
"""Return the scheduler instance.
|
||||||
@ -271,22 +336,22 @@ def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: P
|
|||||||
guild = ctx.channel.guild
|
guild = ctx.channel.guild
|
||||||
|
|
||||||
channels: list[GuildChannel] | list[_BaseChannel] = list(guild.channels) if guild else []
|
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] = []
|
jobs_in_guild: list[Job] = []
|
||||||
for job in jobs:
|
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")
|
guild_id_from_kwargs = int(job.kwargs.get("guild_id", 0))
|
||||||
channel_id_from_kwargs: int | None = job.kwargs.get("channel_id")
|
|
||||||
|
|
||||||
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.")
|
logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
|
||||||
continue
|
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.")
|
logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
logger.debug(f"Adding job: {job.id} to the list.")
|
||||||
jobs_in_guild.append(job)
|
jobs_in_guild.append(job)
|
||||||
|
|
||||||
if len(jobs) != len(jobs_in_guild):
|
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: str = "```md\n"
|
||||||
job_msg += f"# {job.kwargs.get('message', '')}\n"
|
job_msg += f"# {job.kwargs.get('message', '')}\n"
|
||||||
job_msg += f" * {job.id}\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"):
|
if job.kwargs.get("user_id"):
|
||||||
job_msg += f" <@{job.kwargs.get('user_id')}>"
|
job_msg += f" <@{job.kwargs.get('user_id')}>"
|
||||||
if job.kwargs.get("channel_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"):
|
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 += f" {job.kwargs.get('guild_id')}"
|
||||||
|
|
||||||
job_msg += "```"
|
job_msg += "```"
|
||||||
@ -990,6 +1061,92 @@ class RemindGroup(discord.app_commands.Group):
|
|||||||
else:
|
else:
|
||||||
await interaction.followup.send(content="No new reminders were added.")
|
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: discord.Intents = discord.Intents.default()
|
||||||
intents.guild_scheduled_events = True
|
intents.guild_scheduled_events = True
|
||||||
|
@ -29,7 +29,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest"]
|
dev = ["pytest", "nox"]
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "discord-reminder-bot"
|
name = "discord-reminder-bot"
|
||||||
@ -81,6 +81,7 @@ loguru = {version = ">=0.7.3,<1.0.0"}
|
|||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
|
nox = "*"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
Reference in New Issue
Block a user