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
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
- 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

View File

@ -38,9 +38,9 @@ COPY pyproject.toml poetry.lock README.md /home/botuser/
# Change directory to where we will run the bot.
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/"]

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
from typing import List
import dateparser
import discord
from apscheduler.triggers.date import DateTrigger
from discord.errors import NotFound
from discord.ext import commands
from discord_slash import SlashCommand, SlashContext
from discord_slash.error import IncorrectFormat, RequestFailure
from discord_slash.model import SlashCommandOptionType
from discord_slash.utils.manage_commands import create_choice, create_option
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.settings import bot_token, config_timezone, log_level, scheduler, sqlite_location
bot = commands.Bot(
command_prefix="!",
description="Reminder bot for Discord by TheLovinator#9276",
intents=discord.Intents.all(),
from discord_reminder_bot.create_pages import create_pages
from discord_reminder_bot.settings import (
bot_token,
config_timezone,
log_level,
scheduler,
sqlite_location,
)
slash = SlashCommand(bot, sync_commands=True)
bot = interactions.Client(token=bot_token)
# Initialize the wait_for extension.
setup(bot)
@bot.event
async def on_slash_command_error(ctx: SlashContext, ex: Exception) -> None:
"""Handle errors in slash commands.
@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 of the command. Used to get the server name and what channel the command was sent in.
ex: The exception that was raised.
"""
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}"
ctx: The context.
new_date: The new date.
new_message: The new message.
await ctx.send(
f"{message}\nIf this persists, please make an issue on the"
"[GitHub repo](https://github.com/TheLovinator1/discord-reminder-bot/issues) or contact TheLovinator#9276",
hidden=True,
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}"
)
@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:
await ctx.send(f"No reminder with ID ({job_from_dict}).")
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"
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:
await ctx.channel.send("Type the new date. Type Exit to exit.")
# 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...")
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"{response_new_date.clean_content}",
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")
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")
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
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")
channel_name = bot.get_channel(int(channel_id))
message = job.kwargs.get("message")
job_author_id = job.kwargs.get("author_id")
# Only normal reminders have trigger.run_date, 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"**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,
scheduler.modify_job(
job.id,
kwargs={
"channel_id": channel_id,
"message": f"{new_message}",
"author_id": job_author_id,
},
)
if job_number == 24:
await ctx.send("I haven't added support for showing more than 25 reminders yet 🙃")
break
msg += f"**Old message**: {old_message}\n**New message**: {new_message}"
# The empty embed has 76 characters
if len(embed) <= 76:
await ctx.send(f"{ctx.guild.name} has no reminders.")
else:
await ctx.send(embed=embed)
return jobs_dict
return await ctx.send(msg)
@slash.subcommand(base="remind", name="list", description="Show reminders.")
async def remind_list(ctx: SlashContext):
"""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.",
@base_command.subcommand(
name="list", description="List, pause, unpause, and remove reminders."
)
async def remind_resume(ctx: SlashContext):
"""Send a list of reminders to pause to Discord."""
# TODO: Reduce the complexity of this function
jobs_dict = await send_list(ctx, skip_datetriggers=True)
async def list_command(ctx: interactions.CommandContext):
"""List, pause, unpause, and remove reminders."""
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_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}).")
pages = create_pages(ctx)
if not pages:
await ctx.send("No reminders found.")
return
channel_id = job.kwargs.get("channel_id")
channel_name = bot.get_channel(int(channel_id))
message = job.kwargs.get("message")
if len(pages) == 1:
await ctx.send("a")
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
# 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)
await paginator.run()
@slash.subcommand(
base="remind",
@base_command.subcommand(
name="add",
description="Set a reminder.",
options=[
create_option(
Option(
name="message_reason",
description="The message I'm going to send you.",
option_type=SlashCommandOptionType.STRING,
description="The message to send.",
type=OptionType.STRING,
required=True,
),
create_option(
Option(
name="message_date",
description="Time and/or date when you want to get reminded.",
option_type=SlashCommandOptionType.STRING,
description="The date to send the message.",
type=OptionType.STRING,
required=True,
),
create_option(
Option(
name="different_channel",
description="Send the message to a different channel.",
option_type=SlashCommandOptionType.CHANNEL,
description="The channel to send the message to.",
type=OptionType.CHANNEL,
required=False,
),
],
)
async def remind_add(
ctx: SlashContext,
message_date: str,
async def command_add(
ctx: interactions.CommandContext,
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.
@ -442,19 +179,25 @@ async def remind_add(
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}",
},
)
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 different_channel:
channel_id = different_channel.id
channel_id = int(different_channel.id)
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
reminder = scheduler.add_job(
@ -463,12 +206,12 @@ async def remind_add(
kwargs={
"channel_id": channel_id,
"message": message_reason,
"author_id": ctx.author_id,
"author_id": ctx.member.id,
},
)
message = (
f"Hello {ctx.author.display_name},"
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}**."
@ -477,113 +220,112 @@ async def remind_add(
await ctx.send(message)
@slash.subcommand(
base="remind",
@base_command.subcommand(
name="cron",
description="Triggers when current time matches all specified time constraints, similarly to the UNIX cron.",
options=[
create_option(
Option(
name="message_reason",
description="The message I'm going to send you.",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=True,
),
create_option(
Option(
name="year",
description="4-digit year. (Example: 2042)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="month",
description="Month (1-12)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="day",
description="Day of month (1-31)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="week",
description="ISO week (1-53)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
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.",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="hour",
description="Hour (0-23)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="minute",
description="Minute (0-59)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="second",
description="Second (0-59)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="start_date",
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,
),
create_option(
Option(
name="end_date",
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,
),
create_option(
Option(
name="timezone",
description="Time zone to use for the date/time calculations (defaults to scheduler timezone)",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="jitter",
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,
),
create_option(
Option(
name="different_channel",
description="Send the messages to a different channel.",
option_type=SlashCommandOptionType.CHANNEL,
type=OptionType.CHANNEL,
required=False,
),
],
)
async def remind_cron(
ctx: SlashContext,
ctx: interactions.CommandContext,
message_reason: str,
year: int = None,
month: int = None,
day: int = None,
week: int = None,
day_of_week: str = None,
hour: int = None,
minute: int = None,
second: int = None,
start_date: str = None,
end_date: str = None,
timezone: str = None,
jitter: int = None,
different_channel: discord.TextChannel = None,
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.
@ -607,11 +349,13 @@ async def remind_cron(
jitter: Delay the job execution by jitter seconds at most.
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 different_channel:
channel_id = different_channel.id
channel_id = int(different_channel.id)
job = scheduler.add_job(
send_to_discord,
@ -631,13 +375,13 @@ async def remind_cron(
kwargs={
"channel_id": channel_id,
"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
message = (
f"Hello {ctx.author.display_name},"
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}**."
@ -645,92 +389,91 @@ async def remind_cron(
await ctx.send(message)
@slash.subcommand(
base="remind",
@base_command.subcommand(
name="interval",
description="Schedules messages to be run periodically, on selected intervals.",
options=[
create_option(
Option(
name="message_reason",
description="The message I'm going to send you.",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=True,
),
create_option(
Option(
name="weeks",
description="Number of weeks to wait",
option_type=SlashCommandOptionType.INTEGER,
type=OptionType.INTEGER,
required=False,
),
create_option(
Option(
name="days",
description="Number of days to wait",
option_type=SlashCommandOptionType.INTEGER,
type=OptionType.INTEGER,
required=False,
),
create_option(
Option(
name="hours",
description="Number of hours to wait",
option_type=SlashCommandOptionType.INTEGER,
type=OptionType.INTEGER,
required=False,
),
create_option(
Option(
name="minutes",
description="Number of minutes to wait",
option_type=SlashCommandOptionType.INTEGER,
type=OptionType.INTEGER,
required=False,
),
create_option(
Option(
name="seconds",
description="Number of seconds to wait.",
option_type=SlashCommandOptionType.INTEGER,
type=OptionType.INTEGER,
required=False,
),
create_option(
Option(
name="start_date",
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,
),
create_option(
Option(
name="end_date",
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,
),
create_option(
Option(
name="timezone",
description="Time zone to use for the date/time calculations",
option_type=SlashCommandOptionType.STRING,
type=OptionType.STRING,
required=False,
),
create_option(
Option(
name="jitter",
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,
),
create_option(
Option(
name="different_channel",
description="Send the messages to a different channel.",
option_type=SlashCommandOptionType.CHANNEL,
type=OptionType.CHANNEL,
required=False,
),
],
)
async def remind_interval(
ctx: SlashContext,
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,
end_date: str = None,
timezone: str = None,
jitter: int = None,
different_channel: discord.TextChannel = 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 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.
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(
send_to_discord,
@ -766,13 +514,13 @@ async def remind_interval(
kwargs={
"channel_id": channel_id,
"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
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"**{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.
"""
# 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}")
@ -806,7 +561,7 @@ def start():
)
scheduler.start()
bot.run(bot_token)
bot.start()
if __name__ == "__main__":

View File

@ -7,7 +7,7 @@ from dotenv import load_dotenv
load_dotenv(verbose=True)
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="")
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]
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."
authors = ["Joakim Hellsén <tlovinator@gmail.com>"]
license = "GPL-3.0-or-later"
@ -22,19 +22,24 @@ bot = "discord_reminder_bot.main:start"
[tool.poetry.dependencies]
python = "^3.9"
"discord.py" = "^1.7.3"
python-dotenv = "^0.20.0"
discord-py-slash-command = "^3.0.3"
python-dotenv = "^0.21.0"
APScheduler = "^3.9.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]
pytest = "^7.1.2"
mypy = "^0.971"
types-dateparser = "^1.1.4"
types-pytz = "^2022.1.2"
black = "^22.8.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.isort]
profile = "black"

View File

@ -3,6 +3,7 @@
Jobs are stored in memory.
"""
import re
from sched import scheduler
import dateparser
import pytz
@ -17,7 +18,8 @@ class TestCountdown:
"""This tests everything.
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")}
@ -35,8 +37,7 @@ class TestCountdown:
"TO_TIMEZONE": "Europe/Stockholm",
},
)
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S") # type: ignore
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
job = scheduler.add_job(
send_to_discord,
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):
"""Check if calc_countdown returns days, hours and minutes."""
# 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)")
countdown = calculate(self.job)
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