Improve /remind list modify modal

This commit is contained in:
2025-01-04 20:10:30 +01:00
parent 1d57e2f621
commit c028bd6db7
4 changed files with 321 additions and 85 deletions

View File

@ -1,24 +1,25 @@
from __future__ import annotations from __future__ import annotations
import datetime
import logging import logging
import textwrap import textwrap
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
import dateparser
import discord import discord
from apscheduler.job import Job from apscheduler.job import Job
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
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.ui import Button, Select from discord.ui import Button, Select
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from discord_reminder_bot import settings from discord_reminder_bot import settings
from discord_reminder_bot.misc import calculate
from discord_reminder_bot.parser import parse_time
from discord_reminder_bot.ui import ModifyJobModal, create_job_embed
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime
from apscheduler.job import Job from apscheduler.job import Job
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@ -64,38 +65,6 @@ class RemindBotClient(discord.Client):
logger.exception("An HTTP error occurred: %s, %s, %s", e.text, e.status, e.code) logger.exception("An HTTP error occurred: %s, %s, %s", e.text, e.status, e.code)
def parse_time(date_to_parse: str, timezone: str | None = None) -> datetime.datetime | None:
"""Parse a date string into a datetime object.
Args:
date_to_parse(str): The date string to parse.
timezone(str, optional): The timezone to use. Defaults timezone from settings.
Returns:
datetime.datetime: The parsed datetime object.
"""
logger.info("Parsing date: '%s' with timezone: '%s'", date_to_parse, timezone)
if not date_to_parse:
logger.error("No date provided to parse.")
return None
if not timezone:
timezone = settings.config_timezone
parsed_date: datetime.datetime | None = dateparser.parse(
date_string=date_to_parse,
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}",
"RETURN_AS_TIMEZONE_AWARE": True,
"RELATIVE_BASE": datetime.datetime.now(tz=ZoneInfo(timezone)),
},
)
return parsed_date
class RemindGroup(discord.app_commands.Group): class RemindGroup(discord.app_commands.Group):
"""Group for remind commands.""" """Group for remind commands."""
@ -265,50 +234,6 @@ class RemindGroup(discord.app_commands.Group):
await interaction.followup.send(embed=embed, view=view) await interaction.followup.send(embed=embed, view=view)
def calculate(job: Job) -> str:
"""Calculate the time left for a job.
Args:
job: The job to calculate the time for.
Returns:
str: The time left for the job.
"""
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"
return f"<t:{int(trigger_time.timestamp())}:R>"
def create_job_embed(job: Job) -> discord.Embed:
"""Create an embed for a job.
Args:
job: The job to create the embed for.
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=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(),
)
class JobSelector(Select): class JobSelector(Select):
"""Select menu for selecting a job to manage.""" """Select menu for selecting a job to manage."""
@ -395,11 +320,8 @@ class JobManagementView(discord.ui.View):
interaction: The interaction object for the command. interaction: The interaction object for the command.
button: The button that was clicked. button: The button that was clicked.
""" """
next_run = self.job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") modal = ModifyJobModal(self.job, self.scheduler)
await interaction.response.send_message( await interaction.response.send_modal(modal)
f"Current schedule: {next_run}\nPlease use /modify_job command to update the schedule.",
ephemeral=True,
)
@discord.ui.button(label="Pause/Resume", style=discord.ButtonStyle.secondary) @discord.ui.button(label="Pause/Resume", style=discord.ButtonStyle.secondary)
async def pause_button(self, interaction: discord.Interaction, button: Button) -> None: # noqa: ARG002 async def pause_button(self, interaction: discord.Interaction, button: Button) -> None: # noqa: ARG002
@ -417,6 +339,23 @@ class JobManagementView(discord.ui.View):
status = "paused" status = "paused"
await interaction.response.send_message(f"Job '{self.job.name}' has been {status}.", ephemeral=True) await interaction.response.send_message(f"Job '{self.job.name}' has been {status}.", ephemeral=True)
def update_buttons(self) -> None:
"""Update the visibility of buttons based on job status."""
self.pause_button.disabled = not self.job.next_run_time
self.pause_button.label = "Resume" if self.job.next_run_time is None else "Pause"
async def interaction_check(self, interaction: discord.Interaction) -> bool: # noqa: ARG002
"""Check the interaction and update buttons before responding.
Args:
interaction: The interaction object for the command.
Returns:
bool: Whether the interaction is valid.
"""
self.update_buttons()
return True
intents: discord.Intents = discord.Intents.default() intents: discord.Intents = discord.Intents.default()
bot = RemindBotClient(intents=intents) bot = RemindBotClient(intents=intents)

View File

@ -0,0 +1,44 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from apscheduler.triggers.date import DateTrigger
if TYPE_CHECKING:
import datetime
from apscheduler.job import Job
logger: logging.Logger = logging.getLogger(__name__)
def calculate(job: Job) -> str:
"""Calculate the time left for a job.
Args:
job: The job to calculate the time for.
Returns:
str: The time left for the job.
"""
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"
return f"<t:{int(trigger_time.timestamp())}:R>"
def calc_time(time: datetime.datetime) -> str:
"""Convert a datetime object to a Discord timestamp.
Args:
time: The datetime object to convert.
Returns:
str: The Discord timestamp.
"""
return f"<t:{int(time.timestamp())}:R>"

View File

@ -0,0 +1,43 @@
from __future__ import annotations
import datetime
import logging
from zoneinfo import ZoneInfo
import dateparser
from discord_reminder_bot import settings
logger: logging.Logger = logging.getLogger(__name__)
def parse_time(date_to_parse: str, timezone: str | None = None) -> datetime.datetime | None:
"""Parse a date string into a datetime object.
Args:
date_to_parse(str): The date string to parse.
timezone(str, optional): The timezone to use. Defaults timezone from settings.
Returns:
datetime.datetime: The parsed datetime object.
"""
logger.info("Parsing date: '%s' with timezone: '%s'", date_to_parse, timezone)
if not date_to_parse:
logger.error("No date provided to parse.")
return None
if not timezone:
timezone = settings.config_timezone
parsed_date: datetime.datetime | None = dateparser.parse(
date_string=date_to_parse,
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}",
"RETURN_AS_TIMEZONE_AWARE": True,
"RELATIVE_BASE": datetime.datetime.now(tz=ZoneInfo(timezone)),
},
)
return parsed_date

210
discord_reminder_bot/ui.py Normal file
View File

@ -0,0 +1,210 @@
from __future__ import annotations
import logging
import textwrap
from typing import TYPE_CHECKING
import discord
from apscheduler.job import Job
from discord_reminder_bot.misc import calc_time, calculate
from discord_reminder_bot.parser import parse_time
if TYPE_CHECKING:
import datetime
from apscheduler.job import Job
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from discord_reminder_bot import settings
logger: logging.Logger = logging.getLogger(__name__)
class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
"""Modal for modifying a job."""
job_name = discord.ui.TextInput(label="Name", placeholder="Enter new job name")
job_date = discord.ui.TextInput(label="Date", placeholder="Enter new job date")
def __init__(self, job: Job, scheduler: AsyncIOScheduler) -> None:
"""Initialize the modify job modal.
Args:
job: The job to modify.
scheduler: The scheduler to modify the job with.
"""
super().__init__()
self.job: Job = job
self.scheduler: settings.AsyncIOScheduler = scheduler
# Replace placeholders with current values
self.job_name.label = self.get_job_name_label()
self.job_date.label = f"Date ({self.job.next_run_time.strftime('%Y-%m-%d %H:%M:%S')})"
# Replace placeholders with current values
self.job_name.placeholder = self.job.kwargs.get("message", "No message found")
self.job_date.placeholder = self.job.next_run_time.strftime("%Y-%m-%d %H:%M:%S %Z")
logger.info("Job '%s' Modal created", self.job.name)
logger.info("\tCurrent date: '%s'", self.job.next_run_time)
logger.info("\tCurrent message: '%s'", self.job.kwargs.get("message", "N/A"))
logger.info("\tName label: '%s'", self.job_name.label)
logger.info("\tDate label: '%s'", self.job_date.label)
def get_job_name_label(self) -> str:
"""Get the job name label.
Returns:
str: The job name label.
"""
label_max_chars: int = 45
# If name is too long or not provided, use "Name" as label instead
job_name_label: str = f"Name ({self.job.kwargs.get('message', 'X' * (label_max_chars + 1))})"
if len(job_name_label) > label_max_chars:
job_name_label = "Name"
return job_name_label
async def on_submit(self, interaction: discord.Interaction) -> None:
"""Submit the job modifications.
Args:
interaction: The interaction object for the command.
"""
logger.info("Job '%s' modified: Submitting changes", self.job.name)
new_name: str = self.job_name.value
new_date_str: str = self.job_date.value
old_date: datetime.datetime = self.job.next_run_time
if new_date_str != old_date.strftime("%Y-%m-%d %H:%M:%S %Z"):
new_date: datetime.datetime | None = parse_time(new_date_str)
if not new_date:
return await self.report_date_parsing_failure(
interaction=interaction,
new_date_str=new_date_str,
old_date=old_date,
)
await self.update_job_schedule(interaction, old_date, new_date)
if self.job.name != new_name:
await self.update_job_name(interaction, new_name)
return None
async def update_job_schedule(
self,
interaction: discord.Interaction,
old_date: datetime.datetime,
new_date: datetime.datetime,
) -> None:
"""Update the job schedule.
Args:
interaction: The interaction object for the command.
old_date: The old date that was used.
new_date: The new date to use.
"""
logger.info("Job '%s' modified: New date: '%s'", self.job.name, new_date)
logger.info("Job '%s' modified: Old date: '%s'", self.job.name, old_date)
self.job.modify(next_run_time=new_date)
old_date_str: str = old_date.strftime("%Y-%m-%d %H:%M:%S")
new_date_str: str = new_date.strftime("%Y-%m-%d %H:%M:%S")
await interaction.followup.send(
content=(
f"Job **{self.job.name}** was modified by {interaction.user.mention}:\n"
f"Job ID: **{self.job.id}**\n"
f"Old date: **{old_date_str}** {calculate(self.job)} {calc_time(old_date)}\n"
f"New date: **{new_date_str}** {calculate(self.job)} {calc_time(new_date)}"
),
)
async def update_job_name(self, interaction: discord.Interaction, new_name: str) -> None:
"""Update the job name.
Args:
interaction: The interaction object for the command.
new_name: The new name for the job.
"""
logger.info("Job '%s' modified: New name: '%s'", self.job.name, new_name)
logger.info("Job '%s' modified: Old name: '%s'", self.job.name, self.job.name)
self.job.modify(name=new_name)
await interaction.followup.send(
content=(
f"Job **{self.job.name}** was modified by {interaction.user.mention}:\n"
f"Job ID: **{self.job.id}**\n"
f"Old name: **{self.job.name}**\n"
f"New name: **{new_name}**"
),
)
async def report_date_parsing_failure(
self,
interaction: discord.Interaction,
new_date_str: str,
old_date: datetime.datetime,
) -> None:
"""Report a date parsing failure.
Args:
interaction: The interaction object for the command.
new_date_str: The new date string that failed to parse.
old_date: The old date that was used instead.
"""
logger.error("Job '%s' modified: Failed to parse date: '%s'", self.job.name, new_date_str)
await self.on_error(
interaction=interaction,
error=ValueError(
f"Got invalid date for job '{self.job.name}':\nJob ID: {self.job.id}\\Failed to parse date: {new_date_str}", # noqa: E501
),
)
await interaction.followup.send(
content=(
f"Failed modifying job **{self.job.name}**\n"
f"Job ID: **{self.job.id}**\n"
f"Failed to parse date: **{new_date_str}**\n"
f"Defaulting to old date: **{old_date.strftime('%Y-%m-%d %H:%M:%S')}** {calc_time(old_date)}"
),
)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: # noqa: PLR6301
"""Handle an error.
Args:
interaction: The interaction object for the command.
error: The error that occurred.
"""
await interaction.followup.send(f"An error occurred: {error}", ephemeral=True)
def create_job_embed(job: Job) -> discord.Embed:
"""Create an embed for a job.
Args:
job: The job to create the embed for.
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=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(),
)