Use modern Discord Ui stuff, fix time zones and, fix Dockerfile

Changes:
Use buttons, pagination and modals instead of old text-based stuff
Fix time zones (Closes #23)
Fix Dockerfile
Replace Discord.py with discord-py-interactions
Default time zone is now UTC instead of Europe/Stockholm

Replace
  /remind resume
  /remind pause
  /remind remove
  /remind modify
with
  /remind list
This commit is contained in:
2022-09-17 22:23:43 +02:00
parent d5bcf0169b
commit 21be6c00b3
9 changed files with 1251 additions and 982 deletions

View File

@ -1,9 +1,29 @@
# Changelog # Changelog
All notable changes to discord-reminder-bot will be documented in this file. ## [1.0.0] - 2022-09-17
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Added tests for checking if timezones are working correctly
### Changed
- Bot is now using [discord-py-interactions](https://github.com/interactions-py/library) instead of
[discord-py-slash-command](https://github.com/interactions-py/library/tree/legacy-v3) and [discord.py]
(https://github.com/Rapptz/discord.py).
- `/remind pause`, `/remind resume`, `/remind delete`, and `/remind modify` are now buttons under `/remind list`.
- `/remind list` uses pagination now.
- `/remind modify` uses a modal now.
### Fixed
- [Dockerfile](/Dockerfile) was broken, now it is fixed. (Thanks to [FirosStuart](https://github.com/FirosStuart)
for the fix)
- Timezones are now handled correctly. (Thanks to [FirosStuart](https://github.com/FirosStuart))
### Breaking Changes
- You will need the latest version of Poetry to install the dependencies. (Or have Git installed)
## [0.3.0] - 2022-02-19 ## [0.3.0] - 2022-02-19

View File

@ -38,9 +38,9 @@ COPY pyproject.toml poetry.lock README.md /home/botuser/
# Change directory to where we will run the bot. # Change directory to where we will run the bot.
WORKDIR /home/botuser WORKDIR /home/botuser
RUN poetry install --no-interaction --no-ansi --no-dev RUN poetry install --no-interaction --no-ansi --only main
COPY discord_reminder_bot/main.py discord_reminder_bot/settings.py discord_reminder_bot/countdown.py /home/botuser/discord_reminder_bot/ COPY discord_reminder_bot /home/botuser/discord_reminder_bot/
VOLUME ["/home/botuser/data/"] VOLUME ["/home/botuser/data/"]

View File

@ -1 +1 @@
__version__ = "0.3.0" __version__ = "1.0.0"

View File

@ -0,0 +1,197 @@
"""This module creates the pages for the paginator."""
from typing import List
import interactions
from apscheduler.job import Job
from apscheduler.triggers.date import DateTrigger
from interactions import ActionRow, ComponentContext
from interactions.ext.paginator import Page, Paginator, RowPosition
from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.settings import scheduler
def create_pages(ctx) -> list[Page]:
"""Create pages for the paginator.
Args:
ctx (interactions.Context): The context of the command.
Returns:
list[Page]: A list of pages.
"""
pages = []
jobs: List[Job] = scheduler.get_jobs()
for job in jobs:
channel_id = job.kwargs.get("channel_id")
# Only add reminders from channels in the server we run "/reminder list" in
# Check if channel is in the Discord server, if not, skip it.
for channel in ctx.guild.channels:
if int(channel.id) == channel_id:
if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders
trigger_time = job.trigger.run_date
else:
# Get trigger time for cron and interval jobs
trigger_time = job.next_run_time
# Paused reminders returns None
if trigger_time is None:
trigger_value = None
trigger_text = "Paused"
else:
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
trigger_text = trigger_value
message = job.kwargs.get("message")
edit_button = interactions.Button(
label="Edit",
style=interactions.ButtonStyle.PRIMARY,
custom_id="edit",
)
pause_button = interactions.Button(
label="Pause",
style=interactions.ButtonStyle.PRIMARY,
custom_id="pause",
)
unpause_button = interactions.Button(
label="Unpause",
style=interactions.ButtonStyle.PRIMARY,
custom_id="unpause",
)
remove_button = interactions.Button(
label="Remove",
style=interactions.ButtonStyle.DANGER,
custom_id="remove",
)
embed = interactions.Embed(
title=f"{job.id}",
fields=[
interactions.EmbedField(
name=f"**Channel:**",
value=f"#{channel.name}",
),
interactions.EmbedField(
name=f"**Message:**",
value=f"{message}",
),
],
)
if trigger_value is not None:
embed.add_field(
name=f"**Trigger:**",
value=f"{trigger_text}",
)
else:
embed.add_field(
name=f"**Trigger:**",
value=f"_Paused_",
)
components = [
edit_button,
remove_button,
]
if type(job.trigger) is not DateTrigger:
# Get trigger time for cron and interval jobs
trigger_time = job.next_run_time
if trigger_time is None:
pause_or_unpause_button = unpause_button
else:
pause_or_unpause_button = pause_button
components.insert(1, pause_or_unpause_button)
# Add a page to pages list
pages.append(
Page(
embeds=embed,
title=message,
components=ActionRow(components=components), # type: ignore
callback=callback,
position=RowPosition.BOTTOM,
)
)
return pages
async def callback(self: Paginator, ctx: ComponentContext):
"""Callback for the paginator."""
job_id = self.component_ctx.message.embeds[0].title
job = scheduler.get_job(job_id)
if job is None:
await ctx.send("Job not found.")
return
channel_id = job.kwargs.get("channel_id")
old_message = job.kwargs.get("message")
components = [
interactions.TextInput( # type: ignore
style=interactions.TextStyleType.PARAGRAPH,
placeholder=old_message,
label="New message",
custom_id="new_message",
value=old_message,
required=False,
),
]
if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders
trigger_time = job.trigger.run_date
job_type = "normal"
components.append(
interactions.TextInput( # type: ignore
style=interactions.TextStyleType.SHORT,
placeholder=str(trigger_time),
label="New date, Can be human readable or ISO8601",
custom_id="new_date",
value=str(trigger_time),
required=False,
),
)
else:
# Get trigger time for cron and interval jobs
trigger_time = job.next_run_time
job_type = "cron/interval"
if ctx.custom_id == "edit":
await self.end_paginator()
modal = interactions.Modal(
title=f"Edit {job_type} reminder.",
custom_id="edit_modal",
components=components,
)
await ctx.popup(modal)
print(ctx.data)
elif ctx.custom_id == "pause":
await self.end_paginator()
# TODO: Add unpause button if user paused the wrong job
paused_job = scheduler.pause_job(job_id)
print(f"Paused job: {paused_job}")
await ctx.send(f"Job {job_id} paused.")
elif ctx.custom_id == "unpause":
await self.end_paginator()
# TODO: Add pause button if user unpauses the wrong job
scheduler.resume_job(job_id)
await ctx.send(f"Job {job_id} unpaused.")
elif ctx.custom_id == "remove":
await self.end_paginator()
# TODO: Add recreate button if user removed the wrong job
scheduler.remove_job(job_id)
await ctx.send(
f"Job {job_id} removed.\n"
f"**Message:** {old_message}\n"
f"**Channel:** {channel_id}\n"
f"**Time:** {trigger_time}"
)

View File

@ -1,438 +1,175 @@
import logging import logging
from typing import List
import dateparser import dateparser
import discord import interactions
from apscheduler.triggers.date import DateTrigger from apscheduler.jobstores.base import JobLookupError
from discord.errors import NotFound from interactions import CommandContext, Embed, Option, OptionType
from discord.ext import commands from interactions.ext.paginator import Paginator
from discord_slash import SlashCommand, SlashContext from interactions.ext.wait_for import setup
from discord_slash.error import IncorrectFormat, RequestFailure
from discord_slash.model import SlashCommandOptionType
from discord_slash.utils.manage_commands import create_choice, create_option
from discord_reminder_bot.countdown import calculate from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.settings import bot_token, config_timezone, log_level, scheduler, sqlite_location from discord_reminder_bot.create_pages import create_pages
from discord_reminder_bot.settings import (
bot = commands.Bot( bot_token,
command_prefix="!", config_timezone,
description="Reminder bot for Discord by TheLovinator#9276", log_level,
intents=discord.Intents.all(), scheduler,
sqlite_location,
) )
slash = SlashCommand(bot, sync_commands=True)
bot = interactions.Client(token=bot_token)
# Initialize the wait_for extension.
setup(bot)
@bot.event @bot.command(name="remind")
async def on_slash_command_error(ctx: SlashContext, ex: Exception) -> None: async def base_command(ctx: interactions.CommandContext):
"""Handle errors in slash commands. """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: Args:
ctx: The context of the command. Used to get the server name and what channel the command was sent in. ctx: The context.
ex: The exception that was raised. new_date: The new date.
""" new_message: The new message.
logging.error(f"Error occurred during the execution of '/{ctx.name} {ctx.subcommand_name}' by {ctx.author}: {ex}")
if ex == RequestFailure:
message = f"Request to Discord API failed: {ex}"
elif ex == IncorrectFormat:
message = f"Incorrect format: {ex}"
elif ex == NotFound:
message = f"I couldn't find the interaction or it took me longer than 3 seconds to respond: {ex}"
else:
message = f"Error occurred during the execution of '/{ctx.name} {ctx.subcommand_name}': {ex}"
await ctx.send( Returns:
f"{message}\nIf this persists, please make an issue on the" A Discord message with changes.
"[GitHub repo](https://github.com/TheLovinator1/discord-reminder-bot/issues) or contact TheLovinator#9276", """
hidden=True, 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}"
) )
@bot.event
async def on_ready():
"""Print when the bot is ready."""
logging.info(f"Logged in as {bot.user.name}")
@slash.subcommand(
base="remind",
name="modify",
description="Modify a reminder. Does not work with cron or interval.",
options=[
create_option(
name="time_or_message",
description="Choose between modifying the date or the message.",
option_type=SlashCommandOptionType.STRING,
required=True,
choices=[
create_choice(name="Date", value="date"),
create_choice(name="Message", value="message"),
],
),
],
)
async def command_modify(ctx: SlashContext, time_or_message: str):
"""Modify a reminder. You can change time or message.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
time_or_message: Choose between modifying the message or time.
"""
# TODO: Reduce complexity.
# Only make a list with normal reminders.
jobs_dict = await send_list(ctx, skip_cron_or_interval=True)
if time_or_message == "date":
date_or_message = "the date"
else:
date_or_message = "the message"
await ctx.channel.send(
f"Type the corresponding number to the reminder were you wish to change {date_or_message}."
" Does not work with cron or interval. Type Exit to exit."
)
def check(m):
"""Check if the message is from the original user and in the correct channel."""
return m.author == ctx.author and m.channel == ctx.channel
# TODO: Add timeout
response_message = await bot.wait_for("message", check=check)
if response_message.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
for num, job_from_dict in jobs_dict.items():
if int(response_message.clean_content) == num:
job = scheduler.get_job(job_from_dict)
# Get_job() returns None when it can't find a job with that ID.
if job is None: if job is None:
await ctx.send(f"No reminder with ID ({job_from_dict}).") return await ctx.send("Job not found.")
return
message = job.kwargs.get("message")
old_time = calculate(job)
channel_name = bot.get_channel(int(job.kwargs.get("channel_id")))
msg = f"**Modified** {job_from_dict} in #{channel_name}\n"
if time_or_message == "message":
await ctx.channel.send("Type the new message. Type Exit to exit.")
# TODO: Add timeout
response_new_message = await bot.wait_for("message", check=check)
if response_new_message.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
scheduler.modify_job(
job_from_dict,
kwargs={
"channel_id": job.kwargs.get("channel_id"),
"message": f"{response_new_message.clean_content}",
"author_id": job.kwargs.get("author_id"),
},
)
msg += f"**Old message**: {message}\n" f"**New message**: {response_new_message.clean_content}\n"
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: else:
await ctx.channel.send("Type the new date. Type Exit to exit.") return await ctx.send(
f"Unknown field name ({field.name}).", ephemeral=True
# TODO: Add timeout )
response_new_date = await bot.wait_for("message", check=check)
if response_new_date.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
msg = f"Modified job {job_id}.\n"
if new_date != old_date and old_date is not None:
parsed_date = dateparser.parse( parsed_date = dateparser.parse(
f"{response_new_date.clean_content}", f"{new_date}",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
"TIMEZONE": f"{config_timezone}", "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") date_new = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
job = scheduler.reschedule_job(job_from_dict, run_date=date_new) new_job = scheduler.reschedule_job(job.id, run_date=date_new)
new_time = calculate(new_job)
date_old = job.trigger.run_date.strftime("%Y-%m-%d %H:%M") msg += f"**Old date**: {old_date}\n**New date**: {date_new} (in {new_time})"
new_time = calculate(job_from_dict)
msg += f"**Old date**: {date_old} (in {old_time})\n**New date**: {date_new} (in {new_time})"
await ctx.send(msg)
@slash.subcommand(
base="remind",
name="remove",
description="Remove a reminder",
)
async def remind_remove(ctx: SlashContext):
"""Select reminder from list that you want to remove.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
"""
# TODO: Reduce complexity
jobs_dict = await send_list(ctx)
await ctx.channel.send("Type the corresponding number to the reminder you wish to remove. Type Exit to exit.")
def check(m):
"""Check if the message is from the original user and in the correct channel."""
return m.author == ctx.author and m.channel == ctx.channel
# TODO: Add timeout
response_message = await bot.wait_for("message", check=check)
if response_message.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
for num, job_from_dict in jobs_dict.items():
if int(response_message.clean_content) == num:
job = scheduler.get_job(job_from_dict)
if job is None:
await ctx.channel.send(f"No reminder with that ID ({job_from_dict}).")
return
if new_message != old_message and old_message is not None:
channel_id = job.kwargs.get("channel_id") channel_id = job.kwargs.get("channel_id")
channel_name = bot.get_channel(int(channel_id)) job_author_id = job.kwargs.get("author_id")
message = job.kwargs.get("message")
# Only normal reminders have trigger.run_date, cron and scheduler.modify_job(
# interval has next_run_time job.id,
if type(job.trigger) is DateTrigger: kwargs={
trigger_time = job.trigger.run_date "channel_id": channel_id,
else: "message": f"{new_message}",
trigger_time = job.next_run_time "author_id": job_author_id,
},
# Paused reminders returns None
if trigger_time is None:
trigger_value = "Paused - can be resumed with '/remind resume'"
else:
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
msg = f"**Removed** {message} in #{channel_name}.\n**Time**: {trigger_value}"
scheduler.remove_job(job_from_dict)
await ctx.channel.send(msg)
async def send_list(ctx: SlashContext, skip_datetriggers=False, skip_cron_or_interval=False) -> dict:
"""Create a list of reminders.
Args:
ctx: The context of the command. Used to get the server name and what channel the command was sent in.
skip_datetriggers: Only show cron jobs and interval reminders.
skip_cron_or_interval: Only show normal reminders.
Returns:
jobs_dict: Dictionary that contains placement in list and job ID.
"""
# TODO: This will fail if the embed is bigger than 6000 characters.
jobs_dict = {}
job_number = 0
embed = discord.Embed(
colour=discord.Colour.random(),
title="discord-reminder-bot by TheLovinator#9276",
description=f"Reminders for {ctx.guild.name}",
url="https://github.com/TheLovinator1/discord-reminder-bot",
)
jobs = scheduler.get_jobs()
for job in jobs:
channel_id = job.kwargs.get("channel_id")
channel_name = bot.get_channel(int(channel_id))
# Only add reminders from channels in the server we run "/reminder list" in
# Check if channel is in the Discord server, if not, skip it.
for channel in ctx.guild.channels:
if channel.id == channel_id:
if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders
trigger_time = job.trigger.run_date
# Don't add normal reminders if true
if skip_datetriggers:
continue
else:
# Get trigger time for cron and interval jobs
trigger_time = job.next_run_time
# Don't add cron and interval reminders if true
if skip_cron_or_interval:
continue
# Paused reminders returns None
if trigger_time is None:
trigger_value = "Paused"
else:
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
job_number += 1
jobs_dict[job_number] = job.id
message = job.kwargs.get("message")
# Truncate message if it is too long
field_name = f"{job_number}) {message} in #{channel_name}"
field_name = field_name[:253] + (field_name[253:] and "...")
embed.add_field(
name=field_name,
value=trigger_value,
inline=False,
) )
if job_number == 24: msg += f"**Old message**: {old_message}\n**New message**: {new_message}"
await ctx.send("I haven't added support for showing more than 25 reminders yet 🙃")
break
# The empty embed has 76 characters return await ctx.send(msg)
if len(embed) <= 76:
await ctx.send(f"{ctx.guild.name} has no reminders.")
else:
await ctx.send(embed=embed)
return jobs_dict
@slash.subcommand(base="remind", name="list", description="Show reminders.") @base_command.subcommand(
async def remind_list(ctx: SlashContext): name="list", description="List, pause, unpause, and remove reminders."
"""Send a list of reminders to Discord.
Args:
ctx: Context of the slash command. Contains the guild, author and message and more.
"""
await send_list(ctx)
@slash.subcommand(base="remind", name="pause", description="Pause reminder. For cron or interval.")
async def remind_pause(ctx: SlashContext):
"""Get a list of reminders that you can pause."""
jobs_dict = await send_list(ctx, skip_datetriggers=True)
await ctx.channel.send("Type the corresponding number to the reminder you wish to pause. Type Exit to exit.")
def check(m):
"""Check if the message is from the original user and in the correct channel."""
return m.author == ctx.author and m.channel == ctx.channel
# TODO: Add timeout
response_reminder = await bot.wait_for("message", check=check)
if response_reminder.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
# Pair a number with the job ID
for num, job_from_dict in jobs_dict.items():
# Check if the response is a number and if it is in the list.
if int(response_reminder.clean_content) == num:
job = scheduler.get_job(job_from_dict)
channel_id = job.kwargs.get("channel_id")
channel_name = bot.get_channel(int(channel_id))
message = job.kwargs.get("message")
if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders
trigger_time = job.trigger.run_date
else:
# Get trigger time for cron and interval jobs
trigger_time = job.next_run_time
# Tell user if he tries to pause a paused reminder
if trigger_time is None:
return await ctx.channel.send(f"{message} in #{channel_name} is already paused.")
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
msg = f"**Paused** {message} in #{channel_name}.\n**Time**: {trigger_value}"
scheduler.pause_job(job_from_dict)
print(f"Paused {job_from_dict} in #{channel_name}")
await ctx.channel.send(msg)
@slash.subcommand(
base="remind",
name="resume",
description="Resume paused reminder. For cron or interval.",
) )
async def remind_resume(ctx: SlashContext): async def list_command(ctx: interactions.CommandContext):
"""Send a list of reminders to pause to Discord.""" """List, pause, unpause, and remove reminders."""
# TODO: Reduce the complexity of this function
jobs_dict = await send_list(ctx, skip_datetriggers=True)
await ctx.channel.send("Type the corresponding number to the reminder you wish to pause. Type Exit to exit.") pages = create_pages(ctx)
if not pages:
def check(m): await ctx.send("No reminders found.")
"""Check if the message is from the original user and in the correct channel."""
return m.author == ctx.author and m.channel == ctx.channel
# TODO: Add timeout
response_message = await bot.wait_for("message", check=check)
if response_message.clean_content == "Exit":
return await ctx.channel.send("Exiting...")
for num, job_from_dict in jobs_dict.items():
if int(response_message.clean_content) == num:
job = scheduler.get_job(job_from_dict)
if job is None:
await ctx.send(f"No reminder with that ID ({job_from_dict}).")
return return
channel_id = job.kwargs.get("channel_id") if len(pages) == 1:
channel_name = bot.get_channel(int(channel_id)) await ctx.send("a")
message = job.kwargs.get("message") return
scheduler.resume_job(job_from_dict) paginator: Paginator = Paginator(
client=bot,
ctx=ctx,
pages=pages,
remove_after_timeout=True,
author_only=True,
extended_buttons=False,
use_buttons=False,
)
# Only normal reminders have trigger.run_date await paginator.run()
# Cron and interval has next_run_time
if type(job.trigger) is DateTrigger:
trigger_time = job.trigger.run_date
else:
trigger_time = job.next_run_time
# Paused reminders returns None
if trigger_time is None:
trigger_value = "Paused - can be resumed with '/remind resume'"
else:
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
msg = f"**Resumed** {message} in #{channel_name}.\n**Time**: {trigger_value}\n"
await ctx.send(msg)
@slash.subcommand( @base_command.subcommand(
base="remind",
name="add", name="add",
description="Set a reminder.", description="Set a reminder.",
options=[ options=[
create_option( Option(
name="message_reason", name="message_reason",
description="The message I'm going to send you.", description="The message to send.",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=True, required=True,
), ),
create_option( Option(
name="message_date", name="message_date",
description="Time and/or date when you want to get reminded.", description="The date to send the message.",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=True, required=True,
), ),
create_option( Option(
name="different_channel", name="different_channel",
description="Send the message to a different channel.", description="The channel to send the message to.",
option_type=SlashCommandOptionType.CHANNEL, type=OptionType.CHANNEL,
required=False, required=False,
), ),
], ],
) )
async def remind_add( async def command_add(
ctx: SlashContext, ctx: interactions.CommandContext,
message_date: str,
message_reason: str, message_reason: str,
different_channel: discord.TextChannel = None, message_date: str,
different_channel: interactions.Channel | None = None,
): ):
"""Add a new reminder. You can add a date and message. """Add a new reminder. You can add a date and message.
@ -442,19 +179,25 @@ async def remind_add(
message_reason: The message the bot should write when the reminder is triggered. message_reason: The message the bot should write when the reminder is triggered.
different_channel: The channel the reminder should be sent to. different_channel: The channel the reminder should be sent to.
""" """
await ctx.defer()
parsed_date = dateparser.parse( parsed_date = dateparser.parse(
f"{message_date}", f"{message_date}",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
"TIMEZONE": f"{config_timezone}", "TO_TIMEZONE": f"{config_timezone}",
}, },
) )
channel_id = ctx.channel.id 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 we should send the message to a different channel
if different_channel: if different_channel:
channel_id = different_channel.id channel_id = int(different_channel.id)
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S") run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
reminder = scheduler.add_job( reminder = scheduler.add_job(
@ -463,12 +206,12 @@ async def remind_add(
kwargs={ kwargs={
"channel_id": channel_id, "channel_id": channel_id,
"message": message_reason, "message": message_reason,
"author_id": ctx.author_id, "author_id": ctx.member.id,
}, },
) )
message = ( message = (
f"Hello {ctx.author.display_name}," f"Hello {ctx.member.name},"
f" I will notify you in <#{channel_id}> at:\n" f" I will notify you in <#{channel_id}> at:\n"
f"**{run_date}** (in {calculate(reminder)})\n" f"**{run_date}** (in {calculate(reminder)})\n"
f"With the message:\n**{message_reason}**." f"With the message:\n**{message_reason}**."
@ -477,113 +220,112 @@ async def remind_add(
await ctx.send(message) await ctx.send(message)
@slash.subcommand( @base_command.subcommand(
base="remind",
name="cron", name="cron",
description="Triggers when current time matches all specified time constraints, similarly to the UNIX cron.", description="Triggers when current time matches all specified time constraints, similarly to the UNIX cron.",
options=[ options=[
create_option( Option(
name="message_reason", name="message_reason",
description="The message I'm going to send you.", description="The message I'm going to send you.",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=True, required=True,
), ),
create_option( Option(
name="year", name="year",
description="4-digit year. (Example: 2042)", description="4-digit year. (Example: 2042)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="month", name="month",
description="Month (1-12)", description="Month (1-12)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="day", name="day",
description="Day of month (1-31)", description="Day of month (1-31)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="week", name="week",
description="ISO week (1-53)", description="ISO week (1-53)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="day_of_week", 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.", description="Number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun). The first weekday is monday.",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="hour", name="hour",
description="Hour (0-23)", description="Hour (0-23)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="minute", name="minute",
description="Minute (0-59)", description="Minute (0-59)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="second", name="second",
description="Second (0-59)", description="Second (0-59)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="start_date", name="start_date",
description="Earliest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)", description="Earliest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="end_date", name="end_date",
description="Latest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)", description="Latest possible time to trigger on, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="timezone", name="timezone",
description="Time zone to use for the date/time calculations (defaults to scheduler timezone)", description="Time zone to use for the date/time calculations (defaults to scheduler timezone)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="jitter", name="jitter",
description="Delay the job execution by x seconds at most. Adds a random component to the execution time.", description="Delay the job execution by x seconds at most. Adds a random component to the execution time.",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="different_channel", name="different_channel",
description="Send the messages to a different channel.", description="Send the messages to a different channel.",
option_type=SlashCommandOptionType.CHANNEL, type=OptionType.CHANNEL,
required=False, required=False,
), ),
], ],
) )
async def remind_cron( async def remind_cron(
ctx: SlashContext, ctx: interactions.CommandContext,
message_reason: str, message_reason: str,
year: int = None, year: int | None = None,
month: int = None, month: int | None = None,
day: int = None, day: int | None = None,
week: int = None, week: int | None = None,
day_of_week: str = None, day_of_week: str | None = None,
hour: int = None, hour: int | None = None,
minute: int = None, minute: int | None = None,
second: int = None, second: int | None = None,
start_date: str = None, start_date: str | None = None,
end_date: str = None, end_date: str | None = None,
timezone: str = None, timezone: str | None = None,
jitter: int = None, jitter: int | None = None,
different_channel: discord.TextChannel = None, different_channel: interactions.Channel | None = None,
): ):
"""Create new cron job. Works like UNIX cron. """Create new cron job. Works like UNIX cron.
@ -607,11 +349,13 @@ async def remind_cron(
jitter: Delay the job execution by jitter seconds at most. jitter: Delay the job execution by jitter seconds at most.
different_channel: Send the messages to a different channel. different_channel: Send the messages to a different channel.
""" """
channel_id = ctx.channel.id await ctx.defer()
channel_id = int(ctx.channel_id)
# If we should send the message to a different channel # If we should send the message to a different channel
if different_channel: if different_channel:
channel_id = different_channel.id channel_id = int(different_channel.id)
job = scheduler.add_job( job = scheduler.add_job(
send_to_discord, send_to_discord,
@ -631,13 +375,13 @@ async def remind_cron(
kwargs={ kwargs={
"channel_id": channel_id, "channel_id": channel_id,
"message": message_reason, "message": message_reason,
"author_id": ctx.author_id, "author_id": ctx.member.id,
}, },
) )
# TODO: Add what arguments we used in the job to the message # TODO: Add what arguments we used in the job to the message
message = ( message = (
f"Hello {ctx.author.display_name}," f"Hello {ctx.member.name},"
f" I will send messages to <#{channel_id}>.\n" f" I will send messages to <#{channel_id}>.\n"
f"First run in {calculate(job)} with the message:\n" f"First run in {calculate(job)} with the message:\n"
f"**{message_reason}**." f"**{message_reason}**."
@ -645,92 +389,91 @@ async def remind_cron(
await ctx.send(message) await ctx.send(message)
@slash.subcommand( @base_command.subcommand(
base="remind",
name="interval", name="interval",
description="Schedules messages to be run periodically, on selected intervals.", description="Schedules messages to be run periodically, on selected intervals.",
options=[ options=[
create_option( Option(
name="message_reason", name="message_reason",
description="The message I'm going to send you.", description="The message I'm going to send you.",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=True, required=True,
), ),
create_option( Option(
name="weeks", name="weeks",
description="Number of weeks to wait", description="Number of weeks to wait",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="days", name="days",
description="Number of days to wait", description="Number of days to wait",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="hours", name="hours",
description="Number of hours to wait", description="Number of hours to wait",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="minutes", name="minutes",
description="Number of minutes to wait", description="Number of minutes to wait",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="seconds", name="seconds",
description="Number of seconds to wait.", description="Number of seconds to wait.",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="start_date", name="start_date",
description="When to start, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)", description="When to start, in the ISO 8601 format. (Example: 2010-10-10 09:30:00)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="end_date", name="end_date",
description="When to stop, in the ISO 8601 format. (Example: 2014-06-15 11:00:00)", description="When to stop, in the ISO 8601 format. (Example: 2014-06-15 11:00:00)",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="timezone", name="timezone",
description="Time zone to use for the date/time calculations", description="Time zone to use for the date/time calculations",
option_type=SlashCommandOptionType.STRING, type=OptionType.STRING,
required=False, required=False,
), ),
create_option( Option(
name="jitter", name="jitter",
description="Delay the job execution by x seconds at most. Adds a random component to the execution time.", description="Delay the job execution by x seconds at most. Adds a random component to the execution time.",
option_type=SlashCommandOptionType.INTEGER, type=OptionType.INTEGER,
required=False, required=False,
), ),
create_option( Option(
name="different_channel", name="different_channel",
description="Send the messages to a different channel.", description="Send the messages to a different channel.",
option_type=SlashCommandOptionType.CHANNEL, type=OptionType.CHANNEL,
required=False, required=False,
), ),
], ],
) )
async def remind_interval( async def remind_interval(
ctx: SlashContext, ctx: interactions.CommandContext,
message_reason: str, message_reason: str,
weeks: int = 0, weeks: int = 0,
days: int = 0, days: int = 0,
hours: int = 0, hours: int = 0,
minutes: int = 0, minutes: int = 0,
seconds: int = 0, seconds: int = 0,
start_date: str = None, start_date: str | None = None,
end_date: str = None, end_date: str | None = None,
timezone: str = None, timezone: str | None = None,
jitter: int = None, jitter: int | None = None,
different_channel: discord.TextChannel = None, different_channel: interactions.Channel | None = None,
): ):
"""Create a new reminder that triggers based on an interval. """Create a new reminder that triggers based on an interval.
@ -748,8 +491,13 @@ async def remind_interval(
jitter: Delay the job execution by jitter seconds at most. jitter: Delay the job execution by jitter seconds at most.
different_channel: Send the messages to a different channel. different_channel: Send the messages to a different channel.
""" """
await ctx.defer()
channel_id = different_channel.id if different_channel else ctx.channel.id 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( job = scheduler.add_job(
send_to_discord, send_to_discord,
@ -766,13 +514,13 @@ async def remind_interval(
kwargs={ kwargs={
"channel_id": channel_id, "channel_id": channel_id,
"message": message_reason, "message": message_reason,
"author_id": ctx.author_id, "author_id": ctx.member.id,
}, },
) )
# TODO: Add what arguments we used in the job to the message # TODO: Add what arguments we used in the job to the message
message = ( message = (
f"Hello {ctx.author.display_name}, I will send messages to <#{channel_id}>.\n" f"Hello {ctx.member.name}, I will send messages to <#{channel_id}>.\n"
f"First run in {calculate(job)} with the message:\n" f"First run in {calculate(job)} with the message:\n"
f"**{message_reason}**." f"**{message_reason}**."
) )
@ -789,7 +537,14 @@ async def send_to_discord(channel_id: int, message: str, author_id: int):
author_id: User we should ping. author_id: User we should ping.
""" """
# TODO: Check if channel exists. # TODO: Check if channel exists.
channel = bot.get_channel(int(channel_id)) # 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}") await channel.send(f"<@{author_id}>\n{message}")
@ -806,7 +561,7 @@ def start():
) )
scheduler.start() scheduler.start()
bot.run(bot_token) bot.start()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -7,7 +7,7 @@ from dotenv import load_dotenv
load_dotenv(verbose=True) load_dotenv(verbose=True)
sqlite_location = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite") sqlite_location = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite")
config_timezone = os.getenv("TIMEZONE", default="Europe/Stockholm") config_timezone = os.getenv("TIMEZONE", default="UTC")
bot_token = os.getenv("BOT_TOKEN", default="") bot_token = os.getenv("BOT_TOKEN", default="")
log_level = os.getenv(key="LOG_LEVEL", default="INFO") log_level = os.getenv(key="LOG_LEVEL", default="INFO")

1221
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "discord-reminder-bot" name = "discord-reminder-bot"
version = "0.3.0" version = "1.0.0"
description = "Discord bot that allows you to set date, cron and interval reminders." description = "Discord bot that allows you to set date, cron and interval reminders."
authors = ["Joakim Hellsén <tlovinator@gmail.com>"] authors = ["Joakim Hellsén <tlovinator@gmail.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@ -22,19 +22,24 @@ bot = "discord_reminder_bot.main:start"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.9"
"discord.py" = "^1.7.3" python-dotenv = "^0.21.0"
python-dotenv = "^0.20.0"
discord-py-slash-command = "^3.0.3"
APScheduler = "^3.9.1" APScheduler = "^3.9.1"
dateparser = "^1.1.1" dateparser = "^1.1.1"
SQLAlchemy = "^1.4.32" SQLAlchemy = "^1.4.41"
discord-py-interactions = { git = "https://github.com/interactions-py/library.git", rev = "unstable" }
interactions-wait-for = "^1.0.6"
dinteractions-paginator = { git = "https://github.com/interactions-py/paginator.git", rev = "unstable" }
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.2" pytest = "^7.1.2"
mypy = "^0.971" mypy = "^0.971"
types-dateparser = "^1.1.4" types-dateparser = "^1.1.4"
types-pytz = "^2022.1.2" types-pytz = "^2022.1.2"
black = "^22.8.0"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.isort]
profile = "black"

View File

@ -3,6 +3,7 @@
Jobs are stored in memory. Jobs are stored in memory.
""" """
import re import re
from sched import scheduler
import dateparser import dateparser
import pytz import pytz
@ -17,7 +18,8 @@ class TestCountdown:
"""This tests everything. """This tests everything.
This sets up sqlite database in memory, changes scheduler timezone This sets up sqlite database in memory, changes scheduler timezone
to Europe/Stockholm and creates job that runs January 18 2040. to Europe/Stockholm and creates job that runs January 18 2040 and one that
runs at 00:00.
""" """
jobstores = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")} jobstores = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")}
@ -35,8 +37,7 @@ class TestCountdown:
"TO_TIMEZONE": "Europe/Stockholm", "TO_TIMEZONE": "Europe/Stockholm",
}, },
) )
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S") # type: ignore
job = scheduler.add_job( job = scheduler.add_job(
send_to_discord, send_to_discord,
run_date=run_date, run_date=run_date,
@ -47,9 +48,57 @@ class TestCountdown:
}, },
) )
timezone_date = dateparser.parse(
"00:00",
settings={
"PREFER_DATES_FROM": "future",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
timezone_run_date = timezone_date.strftime("%Y-%m-%d %H:%M:%S")
timezone_job = scheduler.add_job(
send_to_discord,
run_date=timezone_run_date,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest at 00:00",
"author_id": 126462229892694018,
},
)
timezone_date2 = dateparser.parse(
"13:37",
settings={
"PREFER_DATES_FROM": "future",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
timezone_run_date2 = timezone_date2.strftime("%Y-%m-%d %H:%M:%S")
timezone_job2 = scheduler.add_job(
send_to_discord,
run_date=timezone_run_date2,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest at 13:37",
"author_id": 126462229892694018,
},
)
def test_countdown(self): def test_countdown(self):
"""Check if calc_countdown returns days, hours and minutes.""" """Check if calc_countdown returns days, hours and minutes."""
# FIXME: This will break when there is 0 seconds/hours/days left # FIXME: This will break when there is 0 seconds/hours/days left
pattern = re.compile(r"\d* (day|days), \d* (hour|hours). \d* (minute|minutes)") pattern = re.compile(r"\d* (day|days), \d* (hour|hours). \d* (minute|minutes)")
countdown = calculate(self.job) countdown = calculate(self.job)
assert pattern.match(countdown) assert pattern.match(countdown)
def test_if_timezones_are_working(self):
"""Check if timezones are working."""
time_job = self.scheduler.get_job(self.timezone_job.id)
assert time_job.trigger.run_date.hour == 0
assert time_job.trigger.run_date.minute == 0
assert time_job.trigger.run_date.second == 0
time_job2 = self.scheduler.get_job(self.timezone_job2.id)
assert time_job2.trigger.run_date.hour == 13
assert time_job2.trigger.run_date.minute == 37
assert time_job2.trigger.run_date.second == 0