Refactor reminder job handling and improve UI formatting; add pagination for reminders

This commit is contained in:
2025-07-02 06:30:53 +02:00
parent acf742c91c
commit 9299ab800d

View File

@ -6,6 +6,7 @@ import os
import platform import platform
import sys import sys
import tempfile import tempfile
from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@ -24,6 +25,7 @@ from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from discord.abc import PrivateChannel from discord.abc import PrivateChannel
from discord.utils import escape_markdown
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
@ -31,9 +33,11 @@ from loguru import logger
from interactions.api.models.misc import Snowflake from interactions.api.models.misc import Snowflake
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from types import CoroutineType
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 requests import Response from requests import Response
@ -291,95 +295,238 @@ class RemindBotClient(discord.Client):
await self.tree.sync(guild=None) await self.tree.sync(guild=None)
def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: PLR0912 def format_job_for_ui(job: Job) -> str:
"""Create a message with all the jobs, splitting messages into chunks of up to 2000 characters. """Format a single job for display in the UI.
Args: Args:
ctx (discord.Interaction): The context of the interaction. job (Job): The job to format.
Returns: Returns:
list[str]: A list of messages with all the jobs. str: The formatted string.
""" """
jobs: list[Job] = scheduler.get_jobs() msg: str = f"**{job.kwargs.get('message', '')}**\n"
msgs: list[str] = [] 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
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
guild_id_from_kwargs = int(job.kwargs.get("guild_id", 0))
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 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):
logger.info(f"Filtered out {len(jobs) - len(jobs_in_guild)} jobs that are not in the current guild.")
jobs = jobs_in_guild
if not jobs:
return ["No scheduled jobs found in the database."]
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"
)
current_msg: str = header
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)}"
if job.kwargs.get("user_id"): if job.kwargs.get("user_id"):
job_msg += f" <@{job.kwargs.get('user_id')}>" msg += f"User: <@{job.kwargs.get('user_id')}>\n"
if job.kwargs.get("channel_id"): if job.kwargs.get("channel_id"):
channel = bot.get_channel(job.kwargs.get("channel_id")) channel = bot.get_channel(job.kwargs.get("channel_id"))
if channel and isinstance(channel, discord.abc.GuildChannel | discord.Thread): if channel and isinstance(channel, discord.abc.GuildChannel | discord.Thread):
job_msg += f" in #{channel.name}" msg += f"Channel: #{channel.name}\n"
if job.kwargs.get("guild_id"): logger.debug(f"Formatted job for UI: {msg}")
guild = bot.get_guild(job.kwargs.get("guild_id")) return msg
if guild:
job_msg += f" in {guild.name}"
job_msg += f" {job.kwargs.get('guild_id')}"
job_msg += "```"
# If adding this job exceeds 2000 characters, push the current message and start a new one. class ReminderListView(discord.ui.View):
if len(current_msg) + len(job_msg) > 2000: """A view for listing reminders with pagination and action buttons."""
msgs.append(current_msg)
current_msg = job_msg 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.
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
self.update_view()
@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)
def update_view(self) -> None:
"""Update the buttons and job actions for the current page."""
self.clear_items()
# Ensure current_page is in valid bounds
self.current_page: int = max(0, min(self.current_page, self.total_pages - 1))
# 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)
# 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"
delete = discord.ui.Button(label="🗑️ Delete", style=discord.ButtonStyle.danger, row=row)
delete.callback = partial(self.handle_delete, job_id=job_id)
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]
idx: int = start + 1
return f"**Your Reminder:**\n```{idx}. {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: else:
current_msg += job_msg await interaction.response.edit_message(content=self.get_page_content(), view=self)
# Append any remaining content in current_msg. async def goto_first_page(self, interaction: discord.Interaction) -> None:
if current_msg: """Go to the first page of reminders."""
msgs.append(current_msg) 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."""
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."""
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."""
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.
"""
await interaction.response.send_message(f"Modify job `{escape_markdown(job_id)}` - Not yet implemented.", ephemeral=True)
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:
await interaction.response.send_message("This is not your reminder list!", ephemeral=True)
return False
return True
class RemindGroup(discord.app_commands.Group): class RemindGroup(discord.app_commands.Group):
@ -591,25 +738,33 @@ class RemindGroup(discord.app_commands.Group):
logger.info(f"Listing reminders for {user} ({user.id}) in {interaction.channel}") logger.info(f"Listing reminders for {user} ({user.id}) in {interaction.channel}")
logger.info(f"Arguments: {locals()}") logger.info(f"Arguments: {locals()}")
jobs: list[Job] = scheduler.get_jobs() all_jobs: list[Job] = scheduler.get_jobs()
if not jobs:
await interaction.followup.send(content="No scheduled jobs found in the database.", ephemeral=True)
return
guild: discord.Guild | None = interaction.guild guild: discord.Guild | None = interaction.guild
if not guild: if not guild:
await interaction.followup.send(content="Failed to get guild.", ephemeral=True) await interaction.followup.send(content="Failed to get guild.", ephemeral=True)
return 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): guild_jobs.append(job)
if i == 0:
await message.edit(content=msg) if not guild_jobs:
else: await interaction.followup.send(content="No scheduled jobs found in this server.", ephemeral=True)
await interaction.followup.send(content=msg) 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 # /remind cron
@discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.") @discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.")
@ -829,13 +984,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: if not dm_and_current_channel:
await interaction.followup.send( await interaction.followup.send(
content=f"Hello {interaction.user.display_name},\n" content=f"Hello {interaction.user.display_name},\n"
f"I will send a DM to {user.display_name} at:\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}**.", f"First run in {calculate(dm_job)} with the message:\n**{message}**.",
) )
return
# Create channel reminder job # Create channel reminder job
channel_job: Job = scheduler.add_job( channel_job: Job = scheduler.add_job(