Improve /remind list
modify modal
This commit is contained in:
@ -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)
|
||||||
|
44
discord_reminder_bot/misc.py
Normal file
44
discord_reminder_bot/misc.py
Normal 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>"
|
43
discord_reminder_bot/parser.py
Normal file
43
discord_reminder_bot/parser.py
Normal 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
210
discord_reminder_bot/ui.py
Normal 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(),
|
||||||
|
)
|
Reference in New Issue
Block a user