Refactor reminder job handling and improve UI formatting; add pagination for reminders
This commit is contained in:
@ -6,6 +6,7 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
import tempfile
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
@ -24,6 +25,7 @@ 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.utils import escape_markdown
|
||||
from discord_webhook import DiscordWebhook
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
@ -31,9 +33,11 @@ from loguru import logger
|
||||
from interactions.api.models.misc import Snowflake
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import CoroutineType
|
||||
|
||||
from discord.guild import GuildChannel
|
||||
from discord.interactions import InteractionChannel
|
||||
from discord.types.channel import _BaseChannel
|
||||
from requests import Response
|
||||
|
||||
|
||||
@ -291,95 +295,238 @@ class RemindBotClient(discord.Client):
|
||||
await self.tree.sync(guild=None)
|
||||
|
||||
|
||||
def generate_reminder_summary(ctx: discord.Interaction) -> list[str]: # noqa: PLR0912
|
||||
"""Create a message with all the jobs, splitting messages into chunks of up to 2000 characters.
|
||||
def format_job_for_ui(job: Job) -> str:
|
||||
"""Format a single job for display in the UI.
|
||||
|
||||
Args:
|
||||
ctx (discord.Interaction): The context of the interaction.
|
||||
job (Job): The job to format.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of messages with all the jobs.
|
||||
str: The formatted string.
|
||||
"""
|
||||
jobs: list[Job] = scheduler.get_jobs()
|
||||
msgs: list[str] = []
|
||||
|
||||
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)}"
|
||||
msg: str = f"**{job.kwargs.get('message', '')}**\n"
|
||||
msg += f"ID: {job.id}\n"
|
||||
msg += f"Trigger: {job.trigger} {get_human_readable_time(job)}\n"
|
||||
|
||||
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"):
|
||||
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}"
|
||||
msg += f"Channel: #{channel.name}\n"
|
||||
|
||||
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')}"
|
||||
logger.debug(f"Formatted job for UI: {msg}")
|
||||
return msg
|
||||
|
||||
job_msg += "```"
|
||||
|
||||
# If adding this job exceeds 2000 characters, push the current message and start a new one.
|
||||
if len(current_msg) + len(job_msg) > 2000:
|
||||
msgs.append(current_msg)
|
||||
current_msg = job_msg
|
||||
class ReminderListView(discord.ui.View):
|
||||
"""A view for listing reminders with pagination and action buttons."""
|
||||
|
||||
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:
|
||||
current_msg += job_msg
|
||||
await interaction.response.edit_message(content=self.get_page_content(), view=self)
|
||||
|
||||
# Append any remaining content in current_msg.
|
||||
if current_msg:
|
||||
msgs.append(current_msg)
|
||||
async def goto_first_page(self, interaction: discord.Interaction) -> None:
|
||||
"""Go to the first page of reminders."""
|
||||
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):
|
||||
@ -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"Arguments: {locals()}")
|
||||
|
||||
jobs: list[Job] = scheduler.get_jobs()
|
||||
if not jobs:
|
||||
await interaction.followup.send(content="No scheduled jobs found in the database.", ephemeral=True)
|
||||
return
|
||||
|
||||
all_jobs: list[Job] = scheduler.get_jobs()
|
||||
guild: discord.Guild | None = interaction.guild
|
||||
if not guild:
|
||||
await interaction.followup.send(content="Failed to get guild.", ephemeral=True)
|
||||
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):
|
||||
if i == 0:
|
||||
await message.edit(content=msg)
|
||||
else:
|
||||
await interaction.followup.send(content=msg)
|
||||
guild_jobs.append(job)
|
||||
|
||||
if not guild_jobs:
|
||||
await interaction.followup.send(content="No scheduled jobs found in this server.", ephemeral=True)
|
||||
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
|
||||
@discord.app_commands.command(name="cron", description="Create new cron job. Works like UNIX cron.")
|
||||
@ -836,6 +991,7 @@ class RemindGroup(discord.app_commands.Group):
|
||||
f"I will send a DM to {user.display_name} at:\n"
|
||||
f"First run in {calculate(dm_job)} with the message:\n**{message}**.",
|
||||
)
|
||||
return
|
||||
|
||||
# Create channel reminder job
|
||||
channel_job: Job = scheduler.add_job(
|
||||
|
Reference in New Issue
Block a user