Files
discord-reminder-bot/discord_reminder_bot/main.py

570 lines
17 KiB
Python

import logging
from typing import List
import dateparser
import interactions
from apscheduler.jobstores.base import JobLookupError
from interactions import CommandContext, Embed, Option, OptionType
from interactions.ext.paginator import Paginator
from interactions.ext.wait_for import setup
from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.create_pages import create_pages
from discord_reminder_bot.settings import (
bot_token,
config_timezone,
log_level,
scheduler,
sqlite_location,
)
bot = interactions.Client(token=bot_token)
# Initialize the wait_for extension.
setup(bot)
@bot.command(name="remind")
async def base_command(ctx: interactions.CommandContext):
"""This description isn't seen in the UI (yet?)
This is the base command for the reminder bot."""
pass
@bot.modal("edit_modal")
async def modal_response_edit(ctx: CommandContext, new_date: str, new_message: str):
"""Edit a reminder.
Args:
ctx: The context.
new_date: The new date.
new_message: The new message.
Returns:
A Discord message with changes.
"""
await ctx.defer()
job_id = ctx.message.embeds[0].title
old_date = None
old_message = None
try:
job = scheduler.get_job(job_id)
except JobLookupError as e:
return await ctx.send(
f"Failed to get the job after the modal.\nJob ID: {job_id}\nError: {e}"
)
if job is None:
return await ctx.send("Job not found.")
message_embeds: List[Embed] = ctx.message.embeds
for embeds in message_embeds:
for field in embeds.fields:
if field.name == "**Channel:**":
continue
elif field.name == "**Message:**":
old_message = field.value
elif field.name == "**Trigger:**":
old_date = field.value
else:
return await ctx.send(
f"Unknown field name ({field.name}).", ephemeral=True
)
msg = f"Modified job {job_id}.\n"
if new_date != old_date and old_date is not None:
parsed_date = dateparser.parse(
f"{new_date}",
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{config_timezone}",
"TO_TIMEZONE": f"{config_timezone}",
},
)
if not parsed_date:
return await ctx.send("Could not parse the date.", ephemeral=True)
date_new = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
new_job = scheduler.reschedule_job(job.id, run_date=date_new)
new_time = calculate(new_job)
msg += f"**Old date**: {old_date}\n**New date**: {date_new} (in {new_time})"
if new_message != old_message and old_message is not None:
channel_id = job.kwargs.get("channel_id")
job_author_id = job.kwargs.get("author_id")
scheduler.modify_job(
job.id,
kwargs={
"channel_id": channel_id,
"message": f"{new_message}",
"author_id": job_author_id,
},
)
msg += f"**Old message**: {old_message}\n**New message**: {new_message}"
return await ctx.send(msg)
@base_command.subcommand(
name="list", description="List, pause, unpause, and remove reminders."
)
async def list_command(ctx: interactions.CommandContext):
"""List, pause, unpause, and remove reminders."""
pages = create_pages(ctx)
if not pages:
await ctx.send("No reminders found.")
return
if len(pages) == 1:
await ctx.send("a")
return
paginator: Paginator = Paginator(
client=bot,
ctx=ctx,
pages=pages,
remove_after_timeout=True,
author_only=True,
extended_buttons=False,
use_buttons=False,
)
await paginator.run()
@base_command.subcommand(
name="add",
description="Set a reminder.",
options=[
Option(
name="message_reason",
description="The message to send.",
type=OptionType.STRING,
required=True,
),
Option(
name="message_date",
description="The date to send the message.",
type=OptionType.STRING,
required=True,
),
Option(
name="different_channel",
description="The channel to send the message to.",
type=OptionType.CHANNEL,
required=False,
),
],
)
async def command_add(
ctx: interactions.CommandContext,
message_reason: str,
message_date: str,
different_channel: interactions.Channel | None = None,
):
"""Add a new reminder. You can add a date and message.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
message_date: The parsed date and time when you want to get reminded.
message_reason: The message the bot should write when the reminder is triggered.
different_channel: The channel the reminder should be sent to.
"""
await ctx.defer()
parsed_date = dateparser.parse(
f"{message_date}",
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{config_timezone}",
"TO_TIMEZONE": f"{config_timezone}",
},
)
if not parsed_date:
await ctx.send("Could not parse the date.")
return
channel_id = int(ctx.channel_id)
# If we should send the message to a different channel
if different_channel:
channel_id = int(different_channel.id)
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
reminder = scheduler.add_job(
send_to_discord,
run_date=run_date,
kwargs={
"channel_id": channel_id,
"message": message_reason,
"author_id": ctx.member.id,
},
)
message = (
f"Hello {ctx.member.name},"
f" I will notify you in <#{channel_id}> at:\n"
f"**{run_date}** (in {calculate(reminder)})\n"
f"With the message:\n**{message_reason}**."
)
await ctx.send(message)
@base_command.subcommand(
name="cron",
description="Triggers when current time matches all specified time constraints, similarly to the UNIX cron.",
options=[
Option(
name="message_reason",
description="The message I'm going to send you.",
type=OptionType.STRING,
required=True,
),
Option(
name="year",
description="4-digit year. (Example: 2042)",
type=OptionType.STRING,
required=False,
),
Option(
name="month",
description="Month (1-12)",
type=OptionType.STRING,
required=False,
),
Option(
name="day",
description="Day of month (1-31)",
type=OptionType.STRING,
required=False,
),
Option(
name="week",
description="ISO week (1-53)",
type=OptionType.STRING,
required=False,
),
Option(
name="day_of_week",
description="Number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun). The first weekday is monday.",
type=OptionType.STRING,
required=False,
),
Option(
name="hour",
description="Hour (0-23)",
type=OptionType.STRING,
required=False,
),
Option(
name="minute",
description="Minute (0-59)",
type=OptionType.STRING,
required=False,
),
Option(
name="second",
description="Second (0-59)",
type=OptionType.STRING,
required=False,
),
Option(
name="start_date",
description="Earliest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
type=OptionType.STRING,
required=False,
),
Option(
name="end_date",
description="Latest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
type=OptionType.STRING,
required=False,
),
Option(
name="timezone",
description="Time zone to use for the date/time calculations (defaults to scheduler timezone)",
type=OptionType.STRING,
required=False,
),
Option(
name="jitter",
description="Delay the job execution by x seconds at most. Adds a random component to the execution time.",
type=OptionType.INTEGER,
required=False,
),
Option(
name="different_channel",
description="Send the messages to a different channel.",
type=OptionType.CHANNEL,
required=False,
),
],
)
async def remind_cron(
ctx: interactions.CommandContext,
message_reason: str,
year: int | None = None,
month: int | None = None,
day: int | None = None,
week: int | None = None,
day_of_week: str | None = None,
hour: int | None = None,
minute: int | None = None,
second: int | None = None,
start_date: str | None = None,
end_date: str | None = None,
timezone: str | None = None,
jitter: int | None = None,
different_channel: interactions.Channel | None = None,
):
"""Create new cron job. Works like UNIX cron.
https://en.wikipedia.org/wiki/Cron
Args that are None will be defaulted to *.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
message_reason: The message the bot should send every time cron job triggers.
year: 4-digit year.
month: Month (1-12).
day: Day of month (1-31).
week: ISO week (1-53).
day_of_week: Number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun).
hour: Hour (0-23).
minute: Minute (0-59).
second: Second (0-59).
start_date: Earliest possible date/time to trigger on (inclusive).
end_date: Latest possible date/time to trigger on (inclusive).
timezone: Time zone to use for the date/time calculations Defaults to scheduler timezone.
jitter: Delay the job execution by jitter seconds at most.
different_channel: Send the messages to a different channel.
"""
await ctx.defer()
channel_id = int(ctx.channel_id)
# If we should send the message to a different channel
if different_channel:
channel_id = int(different_channel.id)
job = scheduler.add_job(
send_to_discord,
"cron",
year=year,
month=month,
day=day,
week=week,
day_of_week=day_of_week,
hour=hour,
minute=minute,
second=second,
start_date=start_date,
end_date=end_date,
timezone=timezone,
jitter=jitter,
kwargs={
"channel_id": channel_id,
"message": message_reason,
"author_id": ctx.member.id,
},
)
# TODO: Add what arguments we used in the job to the message
message = (
f"Hello {ctx.member.name},"
f" I will send messages to <#{channel_id}>.\n"
f"First run in {calculate(job)} with the message:\n"
f"**{message_reason}**."
)
await ctx.send(message)
@base_command.subcommand(
name="interval",
description="Schedules messages to be run periodically, on selected intervals.",
options=[
Option(
name="message_reason",
description="The message I'm going to send you.",
type=OptionType.STRING,
required=True,
),
Option(
name="weeks",
description="Number of weeks to wait",
type=OptionType.INTEGER,
required=False,
),
Option(
name="days",
description="Number of days to wait",
type=OptionType.INTEGER,
required=False,
),
Option(
name="hours",
description="Number of hours to wait",
type=OptionType.INTEGER,
required=False,
),
Option(
name="minutes",
description="Number of minutes to wait",
type=OptionType.INTEGER,
required=False,
),
Option(
name="seconds",
description="Number of seconds to wait.",
type=OptionType.INTEGER,
required=False,
),
Option(
name="start_date",
description="When to start, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
type=OptionType.STRING,
required=False,
),
Option(
name="end_date",
description="When to stop, in the ISO 8601 format. (Example: 2014-06-15 11:00:00)",
type=OptionType.STRING,
required=False,
),
Option(
name="timezone",
description="Time zone to use for the date/time calculations",
type=OptionType.STRING,
required=False,
),
Option(
name="jitter",
description="Delay the job execution by x seconds at most. Adds a random component to the execution time.",
type=OptionType.INTEGER,
required=False,
),
Option(
name="different_channel",
description="Send the messages to a different channel.",
type=OptionType.CHANNEL,
required=False,
),
],
)
async def remind_interval(
ctx: interactions.CommandContext,
message_reason: str,
weeks: int = 0,
days: int = 0,
hours: int = 0,
minutes: int = 0,
seconds: int = 0,
start_date: str | None = None,
end_date: str | None = None,
timezone: str | None = None,
jitter: int | None = None,
different_channel: interactions.Channel | None = None,
):
"""Create a new reminder that triggers based on an interval.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
message_reason: The message we should write when triggered.
weeks: Amount weeks to wait.
days: Amount days to wait.
hours: Amount hours to wait.
minutes: Amount minutes to wait.
seconds: Amount seconds to wait.
start_date: Starting point for the interval calculation.
end_date: Latest possible date/time to trigger on.
timezone: Time zone to use for the date/time calculations.
jitter: Delay the job execution by jitter seconds at most.
different_channel: Send the messages to a different channel.
"""
await ctx.defer()
channel_id = int(ctx.channel_id)
# If we should send the message to a different channel
if different_channel:
channel_id = int(different_channel.id)
job = scheduler.add_job(
send_to_discord,
"interval",
weeks=weeks,
days=days,
hours=hours,
minutes=minutes,
seconds=seconds,
start_date=start_date,
end_date=end_date,
timezone=timezone,
jitter=jitter,
kwargs={
"channel_id": channel_id,
"message": message_reason,
"author_id": ctx.member.id,
},
)
# TODO: Add what arguments we used in the job to the message
message = (
f"Hello {ctx.member.name}, I will send messages to <#{channel_id}>.\n"
f"First run in {calculate(job)} with the message:\n"
f"**{message_reason}**."
)
await ctx.send(message)
async def send_to_discord(channel_id: int, message: str, author_id: int):
"""Send a message to Discord.
Args:
channel_id: The Discord channel ID.
message: The message.
author_id: User we should ping.
"""
# TODO: Check if channel exists.
# TODO: Send message to webhook if channel is not found.
channel = await interactions.get( # type: ignore
bot,
interactions.Channel,
object_id=int(channel_id),
force=interactions.Force.HTTP, # type: ignore
)
await channel.send(f"<@{author_id}>\n{message}")
def start():
"""Start scheduler and log in to Discord."""
# TODO: Add how many reminders are scheduled.
# TODO: Make backup of jobs.sqlite before running the bot.
logging.basicConfig(level=logging.getLevelName(log_level))
logging.info(
f"\nsqlite_location = {sqlite_location}\n"
f"config_timezone = {config_timezone}\n"
f"bot_token = {bot_token}\n"
f"log_level = {log_level}"
)
scheduler.start()
bot.start()
if __name__ == "__main__":
start()