diff --git a/discord_reminder_bot/main.py b/discord_reminder_bot/main.py index 2d1356a..eabe462 100644 --- a/discord_reminder_bot/main.py +++ b/discord_reminder_bot/main.py @@ -2,12 +2,16 @@ from __future__ import annotations import datetime import logging +import textwrap from typing import TYPE_CHECKING from zoneinfo import ZoneInfo import dateparser import discord from apscheduler.job import Job +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.ui import Button, Select from discord_webhook import DiscordWebhook @@ -137,7 +141,7 @@ class RemindGroup(discord.app_commands.Group): where_and_when = "" channel_id: int | None = self.get_channel_id(interaction, channel) if user: - _user_reminder: Job = settings.scheduler.add_job( + user_reminder: Job = settings.scheduler.add_job( send_to_user, run_date=run_date, kwargs={ @@ -150,9 +154,11 @@ class RemindGroup(discord.app_commands.Group): dm_message = f"and a DM to {user.display_name} " if not dm_and_current_channel: should_send_channel_reminder = False - where_and_when: str = f"I will send a DM to {user.display_name} at:\n**{run_date}** (in )\n" + where_and_when: str = ( + f"I will send a DM to {user.display_name} at:\n**{run_date}** (in {calculate(user_reminder)})\n" + ) if should_send_channel_reminder: - _reminder: Job = settings.scheduler.add_job( + reminder: Job = settings.scheduler.add_job( send_to_discord, run_date=run_date, kwargs={ @@ -161,7 +167,9 @@ class RemindGroup(discord.app_commands.Group): "author_id": interaction.user.id, }, ) - where_and_when = f"I will notify you in <#{channel_id}> {dm_message}at:\n**{run_date}** (in)\n" + where_and_when = ( + f"I will notify you in <#{channel_id}> {dm_message}at:\n**{run_date}** (in {calculate(reminder)})\n" + ) final_message: str = f"Hello {interaction.user.display_name}, {where_and_when}With the message:\n**{message}**." await interaction.followup.send(final_message) @@ -257,6 +265,48 @@ class RemindGroup(discord.app_commands.Group): await interaction.followup.send(embed=embed, view=view) +def calculate(job: Job) -> str: + """Get trigger time from a reminder and calculate how many days, hours and minutes till trigger. + + Days/Minutes will not be included if 0. + + Args: + job: The job. Can be cron, interval or normal. + + Returns: + Returns days, hours and minutes till the reminder. Returns "Couldn't calculate time" if no job is found. + """ + trigger_time: datetime.datetime | None = ( + job.trigger.run_date if isinstance(job.trigger, DateTrigger) else job.next_run_time + ) + if trigger_time is None: + logger.error("Couldn't calculate time for job: %s: %s", job.id, job.name) + return "Couldn't calculate time" + + countdown_time: datetime.timedelta = trigger_time - datetime.datetime.now(tz=ZoneInfo(settings.config_timezone)) + + days, hours, minutes = ( + countdown_time.days, + countdown_time.seconds // 3600, + countdown_time.seconds // 60 % 60, + ) + + # Return seconds if only seconds are left. + if days == 0 and hours == 0 and minutes == 0: + seconds: int = countdown_time.seconds % 60 + return f"{seconds} second" + ("s" if seconds != 1 else "") + + return ", ".join( + f"{x} {y}{'s' * (x != 1)}" + for x, y in ( + (days, "day"), + (hours, "hour"), + (minutes, "minute"), + ) + if x + ) + + def create_job_embed(job: Job) -> discord.Embed: """Create an embed for a job. @@ -266,9 +316,18 @@ def create_job_embed(job: Job) -> discord.Embed: Returns: discord.Embed: The embed for the job. """ + next_run_time: datetime.datetime | str = ( + job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") if job.next_run_time else "Paused" + ) + job_kwargs: dict = job.kwargs or {} + channel_id: int = job_kwargs.get("channel_id", 0) + message: str = job_kwargs.get("message", "N/A") + author_id: int = job_kwargs.get("author_id", 0) + embed_title: str = textwrap.shorten(f"{message}", width=256, placeholder="...") + return discord.Embed( - title=f"Job: {job.name}", - description=f"Next run: {job.next_run_time.strftime('%Y-%m-%d %H:%M:%S') if job.next_run_time else 'Paused'}", + title=embed_title, + description=f"ID: {job.id}\nNext run: {next_run_time}\nTime left: {calculate(job)}\nChannel: <#{channel_id}>\nAuthor: <@{author_id}>", # noqa: E501 color=discord.Color.blue(), ) @@ -283,9 +342,32 @@ class JobSelector(Select): scheduler: The scheduler to get the jobs from. """ self.scheduler: settings.AsyncIOScheduler = scheduler - options: list[discord.SelectOption] = [ - discord.SelectOption(label=job.name, value=job.id) for job in settings.scheduler.get_jobs() - ] + options: list[discord.SelectOption] = [] + jobs: list[Job] = scheduler.get_jobs() + + # Only 25 options are allowed in a select menu. + # TODO(TheLovinator): Add pagination for more than 25 jobs. # noqa: TD003 + max_jobs: int = 25 + if len(jobs) > max_jobs: + jobs = jobs[:max_jobs] + + for job in jobs: + job_kwargs: dict = job.kwargs or {} + + label_prefix: str = "" + if job.next_run_time is None: + label_prefix = "Paused: " + # Cron job + elif isinstance(job.trigger, CronTrigger): + label_prefix = "Cron: " + # Interval job + elif isinstance(job.trigger, IntervalTrigger): + label_prefix = "Interval: " + + message: str = job_kwargs.get("message", f"{job.id}") + message: str = textwrap.shorten(f"{label_prefix}{message}", width=100, placeholder="...") + + options.append(discord.SelectOption(label=message, value=job.id)) super().__init__(placeholder="Select a job...", options=options) async def callback(self, interaction: discord.Interaction) -> None: @@ -296,7 +378,7 @@ class JobSelector(Select): """ job: Job | None = self.scheduler.get_job(self.values[0]) if job: - embed = create_job_embed(job) + embed: discord.Embed = create_job_embed(job) view = JobManagementView(job, self.scheduler) await interaction.response.edit_message(embed=embed, view=view)