Add /remind backup
and /remind restore
commands
This commit is contained in:
@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@ -446,6 +449,169 @@ class RemindGroup(discord.app_commands.Group):
|
|||||||
f"First run in {calculate(channel_job)} with the message:\n**{message}**.",
|
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()
|
intents: discord.Intents = discord.Intents.default()
|
||||||
bot = RemindBotClient(intents=intents)
|
bot = RemindBotClient(intents=intents)
|
||||||
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
logger: logging.Logger = logging.getLogger(__name__)
|
logger: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def calculate(job: Job) -> str:
|
def calculate(job: Job) -> str | None:
|
||||||
"""Calculate the time left for a job.
|
"""Calculate the time left for a job.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -28,7 +28,7 @@ def calculate(job: Job) -> str:
|
|||||||
if trigger_time is None:
|
if trigger_time is None:
|
||||||
logger.error("Couldn't calculate time for job: %s: %s", job.id, job.name)
|
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")
|
logger.error("State: %s", job.__getstate__() if hasattr(job, "__getstate__") else "No state")
|
||||||
return "Paused"
|
return None
|
||||||
|
|
||||||
return f"<t:{int(trigger_time.timestamp())}:R>"
|
return f"<t:{int(trigger_time.timestamp())}:R>"
|
||||||
|
|
||||||
|
@ -30,8 +30,8 @@ logger: logging.Logger = logging.getLogger(__name__)
|
|||||||
class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
|
class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
|
||||||
"""Modal for modifying a job."""
|
"""Modal for modifying a job."""
|
||||||
|
|
||||||
job_name = discord.ui.TextInput(label="Name", placeholder="Enter new job name")
|
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")
|
job_date = discord.ui.TextInput(label="Date", placeholder="Enter new job date", required=False)
|
||||||
|
|
||||||
def __init__(self, job: Job, scheduler: AsyncIOScheduler) -> None:
|
def __init__(self, job: Job, scheduler: AsyncIOScheduler) -> None:
|
||||||
"""Initialize the modify job modal.
|
"""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
|
new_date_str: str = self.job_date.value
|
||||||
old_date: datetime.datetime = self.job.next_run_time
|
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)
|
new_date: datetime.datetime | None = parse_time(new_date_str)
|
||||||
if not new_date:
|
if not new_date:
|
||||||
return await self.report_date_parsing_failure(
|
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)
|
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)
|
await self.update_job_name(interaction, new_name)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@ -199,23 +199,27 @@ def create_job_embed(job: Job) -> discord.Embed:
|
|||||||
Returns:
|
Returns:
|
||||||
discord.Embed: The embed for the job.
|
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 {}
|
job_kwargs: dict = job.kwargs or {}
|
||||||
channel_id: int = job_kwargs.get("channel_id", 0)
|
channel_id: int = job_kwargs.get("channel_id", 0)
|
||||||
message: str = job_kwargs.get("message", "N/A")
|
message: str = job_kwargs.get("message", "N/A")
|
||||||
author_id: int = job_kwargs.get("author_id", 0)
|
author_id: int = job_kwargs.get("author_id", 0)
|
||||||
embed_title: str = textwrap.shorten(f"{message}", width=256, placeholder="...")
|
embed_title: str = textwrap.shorten(f"{message}", width=256, placeholder="...")
|
||||||
|
|
||||||
# If trigger is IntervalTrigger, show the interval
|
msg: str = f"ID: {job.id}\n"
|
||||||
interval: str = ""
|
if next_run_time:
|
||||||
|
msg += f"Next run: {next_run_time}\n"
|
||||||
if isinstance(job.trigger, IntervalTrigger):
|
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(
|
return discord.Embed(title=embed_title, description=msg, color=discord.Color.blue())
|
||||||
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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JobSelector(Select):
|
class JobSelector(Select):
|
||||||
@ -238,24 +242,18 @@ class JobSelector(Select):
|
|||||||
jobs = jobs[:max_jobs]
|
jobs = jobs[:max_jobs]
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
job_kwargs: dict = job.kwargs or {}
|
|
||||||
|
|
||||||
label_prefix: str = ""
|
label_prefix: str = ""
|
||||||
if job.next_run_time is None:
|
label_prefix = "Paused: " if job.next_run_time is None else label_prefix
|
||||||
label_prefix = "Paused: "
|
label_prefix = "Interval: " if isinstance(job.trigger, IntervalTrigger) else label_prefix
|
||||||
|
label_prefix = "Cron: " if isinstance(job.trigger, CronTrigger) else label_prefix
|
||||||
# Cron job
|
|
||||||
elif isinstance(job.trigger, CronTrigger):
|
|
||||||
label_prefix = "Cron: "
|
|
||||||
|
|
||||||
# Interval job
|
|
||||||
elif isinstance(job.trigger, IntervalTrigger):
|
|
||||||
label_prefix = "Interval: "
|
|
||||||
|
|
||||||
|
job_kwargs: dict = job.kwargs or {}
|
||||||
message: str = job_kwargs.get("message", f"{job.id}")
|
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))
|
options.append(discord.SelectOption(label=message, value=job.id))
|
||||||
|
|
||||||
super().__init__(placeholder="Select a job...", options=options)
|
super().__init__(placeholder="Select a job...", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction) -> None:
|
async def callback(self, interaction: discord.Interaction) -> None:
|
||||||
@ -286,6 +284,7 @@ class JobManagementView(discord.ui.View):
|
|||||||
self.scheduler: settings.AsyncIOScheduler = scheduler
|
self.scheduler: settings.AsyncIOScheduler = scheduler
|
||||||
self.add_item(JobSelector(scheduler))
|
self.add_item(JobSelector(scheduler))
|
||||||
self.update_buttons()
|
self.update_buttons()
|
||||||
|
|
||||||
logger.debug("JobManagementView created for job: %s", job.id)
|
logger.debug("JobManagementView created for job: %s", job.id)
|
||||||
|
|
||||||
@discord.ui.button(label="Delete", style=discord.ButtonStyle.danger)
|
@discord.ui.button(label="Delete", style=discord.ButtonStyle.danger)
|
||||||
@ -313,7 +312,7 @@ class JobManagementView(discord.ui.View):
|
|||||||
await interaction.response.send_message(msg)
|
await interaction.response.send_message(msg)
|
||||||
self.stop()
|
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.
|
"""Generate the deletion message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -323,15 +322,16 @@ class JobManagementView(discord.ui.View):
|
|||||||
str: The deletion message.
|
str: The deletion message.
|
||||||
"""
|
"""
|
||||||
job_msg: str | int = job_kwargs.get("message", "No message found")
|
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"
|
msg += f"**Job ID**: {self.job.id}\n"
|
||||||
|
|
||||||
# The time the job was supposed to run
|
# The time the job was supposed to run
|
||||||
if hasattr(self.job, "next_run_time"):
|
if hasattr(self.job, "next_run_time"):
|
||||||
if 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:
|
else:
|
||||||
msg += "**Next run time**: Paused\n"
|
msg += "**Next run time**: Paused\n"
|
||||||
|
msg += f"**Trigger**: {self.job.trigger}\n"
|
||||||
else:
|
else:
|
||||||
msg += "**Next run time**: Pending\n"
|
msg += "**Next run time**: Pending\n"
|
||||||
|
|
||||||
@ -351,40 +351,6 @@ class JobManagementView(discord.ui.View):
|
|||||||
if job_kwargs.get("guild_id"):
|
if job_kwargs.get("guild_id"):
|
||||||
msg += f"**Guild**: {job_kwargs.get('guild_id')}\n"
|
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)
|
logger.debug("Deletion message: %s", msg)
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user