diff --git a/discord_reminder_bot/main.py b/discord_reminder_bot/main.py index f7363fc..26418bb 100644 --- a/discord_reminder_bot/main.py +++ b/discord_reminder_bot/main.py @@ -1,7 +1,10 @@ from __future__ import annotations import datetime +import json import logging +import tempfile +from pathlib import Path from typing import TYPE_CHECKING import discord @@ -446,6 +449,169 @@ class RemindGroup(discord.app_commands.Group): f"First run in {calculate(channel_job)} with the message:\n**{message}**.", ) + # /remind backup + @discord.app_commands.command(name="backup", description="Backup all reminders to a file.") + async def backup(self, interaction: discord.Interaction, all_servers: bool = False) -> None: # noqa: FBT001, FBT002, PLR6301 + """Backup all reminders to a file. + + Args: + interaction (discord.Interaction): The interaction object for the command. + all_servers (bool): Backup all servers or just the current server. Defaults to only the current server. + """ + await interaction.response.defer() + + # Retrieve all jobs + with tempfile.NamedTemporaryFile(mode="r+", delete=False, encoding="utf-8", suffix=".json") as temp_file: + # Export jobs to a temporary file + settings.scheduler.export_jobs(temp_file.name) + + # Load the exported jobs + temp_file.seek(0) + jobs_data = json.load(temp_file) + + # Amount of jobs before filtering + amount_of_jobs: int = len(jobs_data.get("jobs", [])) + + if not all_servers: + interaction_channel: InteractionChannel | None = interaction.channel + if not interaction_channel: + await interaction.followup.send(content="Failed to get channel.", ephemeral=True) + return + + if not isinstance(interaction.user, discord.Member): + await interaction.followup.send(content="Failed to get user.", ephemeral=True) + return + + is_admin: bool = interaction_channel.permissions_for(interaction.user).administrator + if not is_admin: + await interaction.followup.send(content="You must be an administrator to backup all servers.", ephemeral=True) + return + + # Can't be 0 because that's the default value for jobs without a guild + guild_id: int = interaction.guild.id if interaction.guild else -1 + channels_in_this_guild: list[int] = [c.id for c in interaction.guild.channels] if interaction.guild else [] + logger.debug("Guild ID: %s, Channels in this guild: %s", guild_id, channels_in_this_guild) + + for job in jobs_data.get("jobs", []): + # Check if the job is in the current guild + job_guild_id = job.get("kwargs", {}).get("guild_id", 0) + if job_guild_id and job_guild_id != guild_id: + logger.debug("Removing job: %s because it's not in the current guild. %s vs %s", job.get("id"), job_guild_id, guild_id) + jobs_data["jobs"].remove(job) + + # Check if the channel is in the current guild + if job.get("kwargs", {}).get("channel_id") not in channels_in_this_guild: + logger.debug("Removing job: %s because it's not in the current guild's channels.", job.get("id")) + jobs_data["jobs"].remove(job) + + msg: str = "All reminders in this server have been backed up." if not all_servers else "All reminders have been backed up." + msg += "\nYou can restore them using `/remind restore`." + + if not all_servers: + msg += f"\nAmount of jobs on all servers: {amount_of_jobs}, in this server: {len(jobs_data.get('jobs', []))}" + msg += "\nYou can use /remind backup all_servers:True to backup all servers." + + # Write the data to a new file + with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", suffix=".json") as output_file: + file_name = f"reminders-backup-{datetime.datetime.now(tz=settings.scheduler.timezone)}.json" + json.dump(jobs_data, output_file, indent=4) + output_file.seek(0) + + await interaction.followup.send(content=msg, file=discord.File(output_file.name, filename=file_name)) + + # /remind restore + @discord.app_commands.command(name="restore", description="Restore reminders from a file.") + async def restore(self, interaction: discord.Interaction) -> None: # noqa: PLR6301 + """Restore reminders from a file. + + Args: + interaction (discord.Interaction): The interaction object for the command. + """ + await interaction.response.defer() + + logger.info("Restoring reminders from file for %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) + + # Get the old jobs + old_jobs: list[Job] = settings.scheduler.get_jobs() + + # Tell to reply with the file to this message + await interaction.followup.send(content="Please reply to this message with the backup file.") + + while True: + # Wait for the reply + try: + reply: discord.Message | None = await bot.wait_for("message", timeout=60, check=lambda m: m.author == interaction.user) + except TimeoutError: + # Modify the original message to say we timed out + await interaction.edit_original_response( + content=("~~Please reply to this message with the backup file.~~\nTimed out after 60 seconds."), + ) + return + + # Fetch the message by its ID to ensure we have the latest data + reply = await reply.channel.fetch_message(reply.id) + + if not reply or not reply.attachments: + await interaction.followup.send(content="No file attached. Please try again.") + continue + break + + # Get the attachment + attachment: discord.Attachment = reply.attachments[0] + if not attachment or not attachment.filename.endswith(".json"): + await interaction.followup.send( + content=f"Invalid file type. Should be a JSON file not '{attachment.filename}'. Please try again.", + ) + return + + jobs_already_exist: list[str] = [] + + # Save the file to a temporary file and import the jobs + with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_file: + logger.info("Saving attachment to %s", temp_file.name) + await attachment.save(Path(temp_file.name)) + + # Load the jobs data from the file + temp_file.seek(0) + jobs_data: dict = json.load(temp_file) + + logger.info("Importing jobs from file") + logger.debug("Jobs data: %s", jobs_data) + + with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_import_file: + # We can't import jobs with the same ID so remove them from the JSON + jobs = [job for job in jobs_data.get("jobs", []) if not settings.scheduler.get_job(job.get("id"))] + jobs_already_exist = [job.get("id") for job in jobs_data.get("jobs", []) if settings.scheduler.get_job(job.get("id"))] + jobs_data["jobs"] = jobs + for job_id in jobs_already_exist: + logger.debug("Removed job: %s because it already exists.", job_id) + + logger.debug("Jobs data after removing existing jobs: %s", jobs_data) + logger.info("Jobs already exist: %s", jobs_already_exist) + + # Write the new data to a temporary file + json.dump(jobs_data, temp_import_file) + temp_import_file.seek(0) + + # Import the jobs + settings.scheduler.import_jobs(temp_import_file.name) + + # Get the new jobs + new_jobs: list[Job] = settings.scheduler.get_jobs() + + # Get the difference + added_jobs: list[Job] = [job for job in new_jobs if job not in old_jobs] + + if added_jobs: + job_info: str = "" + for j in added_jobs: + job_info += f"\n• Message: {j.kwargs.get('message', 'No message found')} | Countdown: {calculate(j) or 'N/A'}" + + msg: str = f"Reminders restored successfully.\nAdded jobs:\n{job_info}" + await interaction.followup.send(content=msg) + else: + await interaction.followup.send(content="No new reminders were added. Note that only jobs for this server will be restored.") + intents: discord.Intents = discord.Intents.default() bot = RemindBotClient(intents=intents) diff --git a/discord_reminder_bot/misc.py b/discord_reminder_bot/misc.py index f85d822..120e216 100644 --- a/discord_reminder_bot/misc.py +++ b/discord_reminder_bot/misc.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: logger: logging.Logger = logging.getLogger(__name__) -def calculate(job: Job) -> str: +def calculate(job: Job) -> str | None: """Calculate the time left for a job. Args: @@ -28,7 +28,7 @@ def calculate(job: Job) -> str: if trigger_time is None: logger.error("Couldn't calculate time for job: %s: %s", job.id, job.name) logger.error("State: %s", job.__getstate__() if hasattr(job, "__getstate__") else "No state") - return "Paused" + return None return f"" diff --git a/discord_reminder_bot/ui.py b/discord_reminder_bot/ui.py index 0d31518..3d904b3 100644 --- a/discord_reminder_bot/ui.py +++ b/discord_reminder_bot/ui.py @@ -30,8 +30,8 @@ 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") + job_name = discord.ui.TextInput(label="Name", placeholder="Enter new job name", required=False) + job_date = discord.ui.TextInput(label="Date", placeholder="Enter new job date", required=False) def __init__(self, job: Job, scheduler: AsyncIOScheduler) -> None: """Initialize the modify job modal. @@ -86,7 +86,7 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"): 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"): + if new_date_str and 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( @@ -97,7 +97,7 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"): await self.update_job_schedule(interaction, old_date, new_date) - if self.job.name != new_name: + if self.job_name.value and self.job.name != new_name: await self.update_job_name(interaction, new_name) return None @@ -199,23 +199,27 @@ 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" + next_run_time = "" + if hasattr(job, "next_run_time"): + next_run_time: str = job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") + 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="...") - # If trigger is IntervalTrigger, show the interval - interval: str = "" + msg: str = f"ID: {job.id}\n" + if next_run_time: + msg += f"Next run: {next_run_time}\n" if isinstance(job.trigger, IntervalTrigger): - interval = f"Interval: {job.trigger.interval} seconds" + msg += f"Interval: {job.trigger.interval}" + if channel_id: + msg += f"Channel: <#{channel_id}>\n" + if author_id: + msg += f"Author: <@{author_id}>" - return discord.Embed( - title=embed_title, - description=f"ID: {job.id}\nNext run: {next_run_time}\nTime left: {calculate(job)}\n{interval}\nChannel: <#{channel_id}>\nAuthor: <@{author_id}>", # noqa: E501 - color=discord.Color.blue(), - ) + return discord.Embed(title=embed_title, description=msg, color=discord.Color.blue()) class JobSelector(Select): @@ -238,24 +242,18 @@ class JobSelector(Select): 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: " + label_prefix = "Paused: " if job.next_run_time is None else label_prefix + label_prefix = "Interval: " if isinstance(job.trigger, IntervalTrigger) else label_prefix + label_prefix = "Cron: " if isinstance(job.trigger, CronTrigger) else label_prefix + job_kwargs: dict = job.kwargs or {} message: str = job_kwargs.get("message", f"{job.id}") - message: str = textwrap.shorten(f"{label_prefix}{message}", width=100, placeholder="...") + message = f"{label_prefix}{message}" + message = message[:96] + "..." if len(message) > 100 else message # noqa: PLR2004 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: @@ -286,6 +284,7 @@ class JobManagementView(discord.ui.View): self.scheduler: settings.AsyncIOScheduler = scheduler self.add_item(JobSelector(scheduler)) self.update_buttons() + logger.debug("JobManagementView created for job: %s", job.id) @discord.ui.button(label="Delete", style=discord.ButtonStyle.danger) @@ -313,7 +312,7 @@ class JobManagementView(discord.ui.View): await interaction.response.send_message(msg) self.stop() - def generate_deletion_message(self, job_kwargs: dict[str, str | int]) -> str: # noqa: C901, PLR0912 + def generate_deletion_message(self, job_kwargs: dict[str, str | int]) -> str: """Generate the deletion message. Args: @@ -323,15 +322,16 @@ class JobManagementView(discord.ui.View): str: The deletion message. """ job_msg: str | int = job_kwargs.get("message", "No message found") - msg: str = f"# Job *{job_msg}* has been deleted.\n" + msg: str = f"**Job '{job_msg}' has been deleted.**\n" msg += f"**Job ID**: {self.job.id}\n" # The time the job was supposed to run if hasattr(self.job, "next_run_time"): if self.job.next_run_time: - msg += f"**Next run time**: ({self.job.next_run_time} {calculate(self.job)})\n" + msg += f"**Next run time**: {self.job.next_run_time} ({calculate(self.job)})\n" else: msg += "**Next run time**: Paused\n" + msg += f"**Trigger**: {self.job.trigger}\n" else: msg += "**Next run time**: Pending\n" @@ -351,40 +351,6 @@ class JobManagementView(discord.ui.View): if job_kwargs.get("guild_id"): msg += f"**Guild**: {job_kwargs.get('guild_id')}\n" - msg += "\n## Debug info\n" - - # Callable (or a textual reference to one) to run at the given time - if self.job.func: - msg += f"**Function**: {self.job.func}\n" - - # Trigger that determines when func is called - if self.job.trigger: - msg += f"**Trigger**: {self.job.trigger}\n" - - # Alias of the executor to run the job with - if self.job.executor: - msg += f"**Executor**: {self.job.executor}\n" - - # List of positional arguments to call func with - if self.job.args: - msg += f"**Args**: {self.job.args}\n" - - # Textual description of the job - if self.job.name: - msg += f"**Name**: {self.job.name}\n" - - # Seconds after the designated runtime that the job is still allowed to be run (or None to allow the job to run no matter how late it is) # noqa: E501 - if self.job.misfire_grace_time: - msg += f"**Misfire grace time**: {self.job.misfire_grace_time}\n" - - # Run once instead of many times if the scheduler determines that the job should be run more than once in succession - if self.job.coalesce: - msg += f"**Coalesce**: {self.job.coalesce}\n" - - # Maximum number of concurrently running instances allowed for this job - if self.job.max_instances: - msg += f"**Max instances**: {self.job.max_instances}\n" - logger.debug("Deletion message: %s", msg) return msg