Use Ruff and fix all its warnings and errors

This commit is contained in:
2023-03-19 00:54:21 +01:00
parent ec2978b529
commit 11d9f2c2a5
10 changed files with 687 additions and 363 deletions

View File

@ -13,7 +13,7 @@ Type `/remind` in a Discord server where this bot exists to get a list of slash
## Installation ## Installation
You have two choices, [install directly on your computer](#Install-directly-on-your-computer) or You have two choices, [install directly on your computer](#install-directly-on-your-computer) or
using [Docker](https://hub.docker.com/r/thelovinator/discord-reminder-bot). using [Docker](https://hub.docker.com/r/thelovinator/discord-reminder-bot).
### Creating a Discord bot token ### Creating a Discord bot token
@ -23,39 +23,39 @@ using [Docker](https://hub.docker.com/r/thelovinator/discord-reminder-bot).
- You can change Icon and Username here. - You can change Icon and Username here.
- Copy the bot token and paste it into the `BOT_TOKEN` environment variable. - Copy the bot token and paste it into the `BOT_TOKEN` environment variable.
- Go to the OAuth2 page -> URL Generator - Go to the OAuth2 page -> URL Generator
- Select the `bot` and `applications.commands` scope. - Select the `bot` and `applications.commands` scope.
- Select the bot permissions that you want the bot to have. Select `Administrator`. (TODO: Add a list of permissions - Select the bot permissions that you want the bot to have. Select `Administrator`. (TODO: Add a list of permissions
that are needed) that are needed)
- Copy the generated URL and open it in your browser. You can now invite the bot to your server. - Copy the generated URL and open it in your browser. You can now invite the bot to your server.
### Install directly on your computer ### Install directly on your computer
- Install the latest version of needed software: - Install the latest version of needed software:
- [Python](https://www.python.org/) - [Python](https://www.python.org/)
- You should use the latest version. - You should use the latest version.
- You want to add Python to your PATH. - You want to add Python to your PATH.
- Windows: Find `App execution aliases` and disable python.exe and python3.exe - Windows: Find `App execution aliases` and disable python.exe and python3.exe
- [Poetry](https://python-poetry.org/docs/master/#installation) - [Poetry](https://python-poetry.org/docs/master/#installation)
- Windows: You have to add `%appdata%\Python\Scripts` to your PATH for Poetry to work. - Windows: You have to add `%appdata%\Python\Scripts` to your PATH for Poetry to work.
- Download project from GitHub with Git or download - Download project from GitHub with Git or download
the [ZIP](https://github.com/TheLovinator1/discord-reminder-bot/archive/refs/heads/master.zip). the [ZIP](https://github.com/TheLovinator1/discord-reminder-bot/archive/refs/heads/master.zip).
- If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again. - If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again.
- Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad). - Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad).
- If you can't see the file extension: - If you can't see the file extension:
- Windows 10: Click the View Tab in File Explorer and click the box next to File name extensions. - Windows 10: Click the View Tab in File Explorer and click the box next to File name extensions.
- Windows 11: Click View -> Show -> File name extensions. - Windows 11: Click View -> Show -> File name extensions.
- Open a terminal in the repository folder. - Open a terminal in the repository folder.
- Windows 10: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and select `Open PowerShell window here` - Windows 10: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and select `Open PowerShell window here`
- Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options - Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options
and `Open PowerShell window here` and `Open PowerShell window here`
- Install requirements: - Install requirements:
- Type `poetry install` into the PowerShell window. Make sure you are - Type `poetry install` into the PowerShell window. Make sure you are
in the repository folder with the [pyproject.toml](pyproject.toml) file. in the repository folder with the [pyproject.toml](pyproject.toml) file.
- You may have to restart your terminal if it can't find the `poetry` command. Also double check it is in - You may have to restart your terminal if it can't find the `poetry` command. Also double check it is in
your PATH. your PATH.
- Start the bot: - Start the bot:
- Type `poetry run bot` into the PowerShell window. - Type `poetry run bot` into the PowerShell window.
- You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>. - You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>.
Note: You will need to run `poetry install` again if poetry.lock has been modified. Note: You will need to run `poetry install` again if poetry.lock has been modified.
@ -66,17 +66,17 @@ Note: It can take up to one hour for the slash commands to be visible in the Dis
Docker Hub: [thelovinator/discord-reminder-bot](https://hub.docker.com/r/thelovinator/discord-reminder-bot) Docker Hub: [thelovinator/discord-reminder-bot](https://hub.docker.com/r/thelovinator/discord-reminder-bot)
- Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad). - Rename .env.example to .env and open it in a text editor (e.g., VSCode, Notepad++, Notepad).
- If you can't see the file extension: - If you can't see the file extension:
- Windows 10: Click the View Tab in File Explorer and click the box next to File name extensions. - Windows 10: Click the View Tab in File Explorer and click the box next to File name extensions.
- Windows 11: Click View -> Show -> File name extensions. - Windows 11: Click View -> Show -> File name extensions.
- Open a terminal in the extras folder. - Open a terminal in the extras folder.
- Windows 10: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and select `Open PowerShell window here` - Windows 10: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and select `Open PowerShell window here`
- Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options - Windows 11: <kbd>Shift</kbd> + <kbd>right-click</kbd> in the folder and Show more options
and `Open PowerShell window here` and `Open PowerShell window here`
- Run the Docker Compose file: - Run the Docker Compose file:
- `docker-compose up` - `docker-compose up`
- You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>. - You can stop the bot with <kbd>Ctrl</kbd> + <kbd>c</kbd>.
- If you want to run the bot in the background, you can run `docker-compose up -d`. - If you want to run the bot in the background, you can run `docker-compose up -d`.
## Help ## Help

View File

@ -1,7 +1,4 @@
""" from datetime import datetime, timedelta
calculate(job) - Calculates how many days, hours and minutes till trigger.
"""
import datetime
import pytz import pytz
from apscheduler.job import Job from apscheduler.job import Job
@ -11,8 +8,7 @@ from discord_reminder_bot.settings import config_timezone
def calculate(job: Job) -> str: def calculate(job: Job) -> str:
"""Get trigger time from a reminder and calculate how many days, """Get trigger time from a reminder and calculate how many days, hours and minutes till trigger.
hours and minutes till trigger.
Days/Minutes will not be included if 0. Days/Minutes will not be included if 0.
@ -25,10 +21,7 @@ def calculate(job: Job) -> str:
# TODO: This "breaks" when only seconds are left. # TODO: This "breaks" when only seconds are left.
# If we use (in {calc_countdown(job)}) it will show (in ) # If we use (in {calc_countdown(job)}) it will show (in )
if type(job.trigger) is DateTrigger: trigger_time: datetime | None = job.trigger.run_date if type(job.trigger) is DateTrigger else job.next_run_time
trigger_time = job.trigger.run_date
else:
trigger_time = job.next_run_time
# Get_job() returns None when it can't find a job with that ID. # Get_job() returns None when it can't find a job with that ID.
if trigger_time is None: if trigger_time is None:
@ -41,8 +34,7 @@ def calculate(job: Job) -> str:
def countdown(trigger_time: datetime) -> str: def countdown(trigger_time: datetime) -> str:
""" """Calculate days, hours and minutes to a date.
Calculate days, hours and minutes to a date.
Args: Args:
trigger_time: The date. trigger_time: The date.
@ -50,7 +42,7 @@ def countdown(trigger_time: datetime) -> str:
Returns: Returns:
A string with the days, hours and minutes. A string with the days, hours and minutes.
""" """
countdown_time = trigger_time - datetime.datetime.now(tz=pytz.timezone(config_timezone)) countdown_time: timedelta = trigger_time - datetime.now(tz=pytz.timezone(config_timezone))
days, hours, minutes = ( days, hours, minutes = (
countdown_time.days, countdown_time.days,
@ -60,7 +52,7 @@ def countdown(trigger_time: datetime) -> str:
# Return seconds if only seconds are left. # Return seconds if only seconds are left.
if days == 0 and hours == 0 and minutes == 0: if days == 0 and hours == 0 and minutes == 0:
seconds = countdown_time.seconds % 60 seconds: int = countdown_time.seconds % 60
return f"{seconds} second" + ("s" if seconds != 1 else "") return f"{seconds} second" + ("s" if seconds != 1 else "")
# TODO: Explain this. # TODO: Explain this.

View File

@ -1,17 +1,23 @@
"""This module creates the pages for the paginator.""" from typing import TYPE_CHECKING, Literal
from typing import List
import interactions import interactions
from apscheduler.job import Job
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from interactions import ActionRow, CommandContext, ComponentContext from interactions import ActionRow, Button, CommandContext, ComponentContext, Embed, Message, Modal, TextInput
from interactions.ext.paginator import Page, Paginator, RowPosition from interactions.ext.paginator import Page, Paginator, RowPosition
from discord_reminder_bot.countdown import calculate from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.settings import scheduler from discord_reminder_bot.settings import scheduler
if TYPE_CHECKING:
from datetime import datetime
def create_pages(ctx: CommandContext) -> list[Page]: from apscheduler.job import Job
max_message_length: Literal[1010] = 1010
max_title_length: Literal[90] = 90
async def create_pages(ctx: CommandContext) -> list[Page]:
"""Create pages for the paginator. """Create pages for the paginator.
Args: Args:
@ -20,65 +26,69 @@ def create_pages(ctx: CommandContext) -> list[Page]:
Returns: Returns:
list[Page]: A list of pages. list[Page]: A list of pages.
""" """
pages = [] pages: list[Page] = []
jobs: List[Job] = scheduler.get_jobs() jobs: list[Job] = scheduler.get_jobs()
for job in jobs: for job in jobs:
channel_id = job.kwargs.get("channel_id") channel_id: int = job.kwargs.get("channel_id")
guild_id = job.kwargs.get("guild_id") guild_id: int = job.kwargs.get("guild_id")
if ctx.guild is None:
await ctx.send("I can't find the server you're in. Are you sure you're in a server?", ephemeral=True)
return pages
if ctx.guild.channels is None:
await ctx.send("I can't find the channel you're in.", ephemeral=True)
return pages
# Only add reminders from channels in the server we run "/reminder list" in # 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. # Check if channel is in the Discord server, if not, skip it.
for channel in ctx.guild.channels: for channel in ctx.guild.channels:
if int(channel.id) == channel_id or ctx.guild_id == guild_id: if int(channel.id) == channel_id or ctx.guild_id == guild_id:
if type(job.trigger) is DateTrigger: trigger_time: datetime | None = (
# Get trigger time for normal reminders job.trigger.run_date if type(job.trigger) is DateTrigger else job.next_run_time
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 # Paused reminders returns None
if trigger_time is None: if trigger_time is None:
trigger_value = None trigger_value: str | None = None
trigger_text = "Paused" trigger_text: str = "Paused"
else: else:
trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})' trigger_value = f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
trigger_text = trigger_value trigger_text = trigger_value
message = job.kwargs.get("message") message: str = job.kwargs.get("message")
message = f"{message[:1000]}..." if len(message) > 1010 else message message = f"{message[:1000]}..." if len(message) > max_message_length else message
edit_button = interactions.Button( edit_button: Button = interactions.Button(
label="Edit", label="Edit",
style=interactions.ButtonStyle.PRIMARY, style=interactions.ButtonStyle.PRIMARY,
custom_id="edit", custom_id="edit",
) )
pause_button = interactions.Button( pause_button: Button = interactions.Button(
label="Pause", label="Pause",
style=interactions.ButtonStyle.PRIMARY, style=interactions.ButtonStyle.PRIMARY,
custom_id="pause", custom_id="pause",
) )
unpause_button = interactions.Button( unpause_button: Button = interactions.Button(
label="Unpause", label="Unpause",
style=interactions.ButtonStyle.PRIMARY, style=interactions.ButtonStyle.PRIMARY,
custom_id="unpause", custom_id="unpause",
) )
remove_button = interactions.Button( remove_button: Button = interactions.Button(
label="Remove", label="Remove",
style=interactions.ButtonStyle.DANGER, style=interactions.ButtonStyle.DANGER,
custom_id="remove", custom_id="remove",
) )
embed = interactions.Embed( embed: Embed = interactions.Embed(
title=f"{job.id}", title=f"{job.id}",
fields=[ fields=[
interactions.EmbedField( interactions.EmbedField(
name=f"**Channel:**", name="**Channel:**",
value=f"#{channel.name}", value=f"#{channel.name}",
), ),
interactions.EmbedField( interactions.EmbedField(
name=f"**Message:**", name="**Message:**",
value=f"{message}", value=f"{message}",
), ),
], ],
@ -86,16 +96,16 @@ def create_pages(ctx: CommandContext) -> list[Page]:
if trigger_value is not None: if trigger_value is not None:
embed.add_field( embed.add_field(
name=f"**Trigger:**", name="**Trigger:**",
value=f"{trigger_text}", value=f"{trigger_text}",
) )
else: else:
embed.add_field( embed.add_field(
name=f"**Trigger:**", name="**Trigger:**",
value=f"_Paused_", value="_Paused_",
) )
components = [ components: list[Button] = [
edit_button, edit_button,
remove_button, remove_button,
] ]
@ -103,39 +113,42 @@ def create_pages(ctx: CommandContext) -> list[Page]:
if type(job.trigger) is not DateTrigger: if type(job.trigger) is not DateTrigger:
# Get trigger time for cron and interval jobs # Get trigger time for cron and interval jobs
trigger_time = job.next_run_time trigger_time = job.next_run_time
if trigger_time is None: pause_or_unpause_button: Button = unpause_button if trigger_time is None else pause_button
pause_or_unpause_button = unpause_button
else:
pause_or_unpause_button = pause_button
components.insert(1, pause_or_unpause_button) components.insert(1, pause_or_unpause_button)
# Add a page to pages list # Add a page to pages list
title = f"{message[:87]}..." if len(message) > 90 else message title: str = f"{message[:87]}..." if len(message) > max_title_length else message
pages.append( pages.append(
Page( Page(
embeds=embed, embeds=embed,
title=title, title=title,
components=ActionRow(components=components), components=ActionRow(components=components), # type: ignore # noqa: PGH003
callback=callback, callback=callback,
position=RowPosition.BOTTOM, position=RowPosition.BOTTOM,
) ),
) )
return pages return pages
async def callback(self: Paginator, ctx: ComponentContext): async def callback(self: Paginator, ctx: ComponentContext) -> Message | None: # noqa: PLR0911
"""Callback for the paginator.""" """Callback for the paginator."""
job_id = self.component_ctx.message.embeds[0].title if self.component_ctx is None:
job = scheduler.get_job(job_id) return await ctx.send("Something went wrong.", ephemeral=True)
if self.component_ctx.message is None:
return await ctx.send("Something went wrong.", ephemeral=True)
job_id: str | None = self.component_ctx.message.embeds[0].title
job: Job | None = scheduler.get_job(job_id)
if job is None: if job is None:
return await ctx.send("Job not found.", ephemeral=True) return await ctx.send("Job not found.", ephemeral=True)
channel_id = job.kwargs.get("channel_id") channel_id: int = job.kwargs.get("channel_id")
old_message = job.kwargs.get("message") old_message: str = job.kwargs.get("message")
components = [ components: list[TextInput] = [
interactions.TextInput( interactions.TextInput(
style=interactions.TextStyleType.PARAGRAPH, style=interactions.TextStyleType.PARAGRAPH,
label="New message", label="New message",
@ -147,8 +160,8 @@ async def callback(self: Paginator, ctx: ComponentContext):
if type(job.trigger) is DateTrigger: if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders # Get trigger time for normal reminders
trigger_time = job.trigger.run_date trigger_time: datetime | None = job.trigger.run_date
job_type = "normal" job_type: str = "normal"
components.append( components.append(
interactions.TextInput( interactions.TextInput(
style=interactions.TextStyleType.SHORT, style=interactions.TextStyleType.SHORT,
@ -165,29 +178,31 @@ async def callback(self: Paginator, ctx: ComponentContext):
job_type = "cron/interval" job_type = "cron/interval"
if ctx.custom_id == "edit": if ctx.custom_id == "edit":
modal = interactions.Modal( modal: Modal = interactions.Modal(
title=f"Edit {job_type} reminder.", title=f"Edit {job_type} reminder.",
custom_id="edit_modal", custom_id="edit_modal",
components=components, components=components, # type: ignore # noqa: PGH003
) )
await ctx.popup(modal) await ctx.popup(modal)
return None
elif ctx.custom_id == "pause": if ctx.custom_id == "pause":
# TODO: Add unpause button if user paused the wrong job
scheduler.pause_job(job_id) scheduler.pause_job(job_id)
await ctx.send(f"Job {job_id} paused.") await ctx.send(f"Job {job_id} paused.")
return None
elif ctx.custom_id == "unpause": if ctx.custom_id == "unpause":
# TODO: Add pause button if user unpauses the wrong job
scheduler.resume_job(job_id) scheduler.resume_job(job_id)
await ctx.send(f"Job {job_id} unpaused.") await ctx.send(f"Job {job_id} unpaused.")
return None
elif ctx.custom_id == "remove": if ctx.custom_id == "remove":
# TODO: Add recreate button if user removed the wrong job
scheduler.remove_job(job_id) scheduler.remove_job(job_id)
await ctx.send( await ctx.send(
f"Job {job_id} removed.\n" f"Job {job_id} removed.\n"
f"**Message:** {old_message}\n" f"**Message:** {old_message}\n"
f"**Channel:** {channel_id}\n" f"**Channel:** {channel_id}\n"
f"**Time:** {trigger_time}" f"**Time:** {trigger_time}",
) )
return None
return None

View File

@ -1,71 +1,83 @@
import logging import logging
from typing import List from typing import TYPE_CHECKING
import interactions import interactions
from apscheduler import events from apscheduler import events
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED, JobExecutionEvent
from apscheduler.jobstores.base import JobLookupError from apscheduler.jobstores.base import JobLookupError
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from interactions import CommandContext, Embed, OptionType, autodefer from interactions import Channel, Client, CommandContext, Embed, Member, Message, OptionType, autodefer
from interactions.ext.paginator import Paginator from interactions.ext.paginator import Page, Paginator
from discord_reminder_bot.countdown import calculate from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.create_pages import create_pages from discord_reminder_bot.create_pages import create_pages
from discord_reminder_bot.parse import parse_time from discord_reminder_bot.parse import ParsedTime, parse_time
from discord_reminder_bot.settings import ( from discord_reminder_bot.settings import (
bot_token, bot_token,
config_timezone, config_timezone,
log_level, log_level,
scheduler, scheduler,
sqlite_location, sqlite_location,
webhook_url webhook_url,
) )
bot = interactions.Client(token=bot_token) if TYPE_CHECKING:
from datetime import datetime
from apscheduler.job import Job
bot: Client = interactions.Client(token=bot_token)
def send_webhook(url=webhook_url, message: str = "discord-reminder-bot: Empty message."): def send_webhook(
""" url: str = webhook_url,
Send a webhook to Discord. message: str = "discord-reminder-bot: Empty message.",
) -> None:
"""Send a webhook to Discord.
Args: Args:
url: Our webhook url, defaults to the one from settings. url: Our webhook url, defaults to the one from settings.
message: The message that will be sent to Discord. message: The message that will be sent to Discord.
""" """
if not url: if not url:
print("ERROR: Tried to send a webhook but you have no webhook url configured.") logging.error("ERROR: Tried to send a webhook but you have no webhook url configured.")
return return
webhook = DiscordWebhook(url=url, content=message, rate_limit_retry=True) webhook: DiscordWebhook = DiscordWebhook(url=url, content=message, rate_limit_retry=True)
webhook.execute() webhook.execute()
@bot.command(name="remind") @bot.command(name="remind")
async def base_command(ctx: interactions.CommandContext): async def base_command(ctx: interactions.CommandContext) -> None: # noqa: ARG001
"""This is the base command for the reminder bot.""" """This is the base command for the reminder bot."""
pass
@bot.modal("edit_modal") @bot.modal("edit_modal")
async def modal_response_edit(ctx: CommandContext, *response: str): async def modal_response_edit(ctx: CommandContext, *response: str) -> Message: # noqa: C901, PLR0912, PLR0911
"""This is what gets triggerd when the user clicks the Edit button in /reminder list. """This is what gets triggered when the user clicks the Edit button in /reminder list.
Args: Args:
ctx: Context of the slash command. Contains the guild, author and message and more. ctx: Context of the slash command. Contains the guild, author and message and more.
response: The response from the modal.
Returns: Returns:
A Discord message with changes. A Discord message with changes.
""" """
job_id = ctx.message.embeds[0].title if not ctx.message:
old_date = None return await ctx.send(
old_message = None "The message that triggered this modal is missing. Or something else went wrong.",
ephemeral=True,
)
job_id: str | None = ctx.message.embeds[0].title
old_date: str | None = None
old_message: str | None = None
try: try:
job = scheduler.get_job(job_id) job: Job | None = scheduler.get_job(job_id)
except JobLookupError as e: except JobLookupError as e:
return await ctx.send( return await ctx.send(
f"Failed to get the job after the modal.\n" f"Failed to get the job after the modal.\nJob ID: {job_id}\nError: {e}",
f"Job ID: {job_id}\n"
f"Error: {e}",
ephemeral=True, ephemeral=True,
) )
@ -76,50 +88,54 @@ async def modal_response_edit(ctx: CommandContext, *response: str):
return await ctx.send("No changes made.", ephemeral=True) return await ctx.send("No changes made.", ephemeral=True)
if type(job.trigger) is DateTrigger: if type(job.trigger) is DateTrigger:
new_message = response[0] new_message: str | None = response[0]
new_date = response[1] new_date: str | None = response[1]
else: else:
new_message = response[0] new_message = response[0]
new_date = None new_date = None
message_embeds: List[Embed] = ctx.message.embeds message_embeds: list[Embed] = ctx.message.embeds
for embeds in message_embeds: for embeds in message_embeds:
if embeds.fields is None:
return await ctx.send("No fields found in the embed.", ephemeral=True)
for field in embeds.fields: for field in embeds.fields:
if field.name == "**Channel:**": if field.name == "**Channel:**":
continue continue
elif field.name == "**Message:**": if field.name == "**Message:**":
old_message = field.value old_message = field.value
elif field.name == "**Trigger:**": if field.name == "**Trigger:**":
old_date = field.value old_date = field.value
else: else:
return await ctx.send( return await ctx.send(
f"Unknown field name ({field.name}).", ephemeral=True f"Unknown field name ({field.name}).",
ephemeral=True,
) )
msg = f"Modified job {job_id}.\n" msg: str = f"Modified job {job_id}.\n"
if old_date is not None: if old_date is not None and new_date:
if new_date: # Parse the time/date we got from the command.
# Parse the time/date we got from the command. parsed: ParsedTime = parse_time(date_to_parse=new_date)
parsed = parse_time(date_to_parse=new_date) if parsed.err:
if parsed.err: return await ctx.send(parsed.err_msg)
return await ctx.send(parsed.err_msg) parsed_date: datetime | None = parsed.parsed_time
parsed_date = parsed.parsed_time
date_new = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
new_job = scheduler.reschedule_job(job.id, run_date=date_new) if parsed_date is None:
new_time = calculate(new_job) return await ctx.send(f"Failed to parse the date. ({new_date})")
# TODO: old_date and date_new has different precision. date_new: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
# Old date: 2032-09-18 00:07
# New date: 2032-09-18 00:07:13 new_job: Job = scheduler.reschedule_job(job.id, run_date=date_new)
msg += ( new_time: str = calculate(new_job)
f"**Old date**: {old_date}\n"
f"**New date**: {date_new} (in {new_time})\n" # TODO: old_date and date_new has different precision.
) # Old date: 2032-09-18 00:07
# New date: 2032-09-18 00:07:13
msg += f"**Old date**: {old_date}\n**New date**: {date_new} (in {new_time})\n"
if old_message is not None: if old_message is not None:
channel_id = job.kwargs.get("channel_id") channel_id: int = job.kwargs.get("channel_id")
job_author_id = job.kwargs.get("author_id") job_author_id: int = job.kwargs.get("author_id")
try: try:
scheduler.modify_job( scheduler.modify_job(
job.id, job.id,
@ -131,9 +147,7 @@ async def modal_response_edit(ctx: CommandContext, *response: str):
) )
except JobLookupError as e: except JobLookupError as e:
return await ctx.send( return await ctx.send(
f"Failed to modify the job.\n" f"Failed to modify the job.\nJob ID: {job_id}\nError: {e}",
f"Job ID: {job_id}\n"
f"Error: {e}",
ephemeral=True, ephemeral=True,
) )
msg += f"**Old message**: {old_message}\n**New message**: {new_message}\n" msg += f"**Old message**: {old_message}\n**New message**: {new_message}\n"
@ -142,44 +156,65 @@ async def modal_response_edit(ctx: CommandContext, *response: str):
@autodefer() @autodefer()
@bot.command(name="parse", description="Parse the time from a string") @bot.command(name="parse", description="Parse the time from a string") # type: ignore # noqa: PGH003
@interactions.option(name="time_to_parse", description="The string you want to parse.", type=OptionType.STRING, required=True) @interactions.option(
@interactions.option(name="optional_timezone", description="Optional time zone, for example Europe/Stockholm", type=OptionType.STRING, required=False) name="time_to_parse",
async def parse_command(ctx: interactions.CommandContext, time_to_parse: str, optional_timezone: str | None = None): description="The string you want to parse.",
""" type=OptionType.STRING,
Find the date and time from a string. required=True,
)
@interactions.option(
name="optional_timezone",
description="Optional time zone, for example Europe/Stockholm",
type=OptionType.STRING,
required=False,
)
async def parse_command(
ctx: interactions.CommandContext,
time_to_parse: str,
optional_timezone: str | None = None,
) -> Message:
"""Find the date and time from a string.
Args: Args:
ctx: Context of the slash command. Contains the guild, author and message and more. ctx: Context of the slash command. Contains the guild, author and message and more.
time_to_parse: The string you want to parse. time_to_parse: The string you want to parse.
optional_timezone: Optional time zone, for example Europe/Stockholm. optional_timezone: Optional time zone, for example Europe/Stockholm.
""" """
if optional_timezone: if optional_timezone:
parsed = parse_time(date_to_parse=time_to_parse, timezone=optional_timezone) parsed: ParsedTime = parse_time(date_to_parse=time_to_parse, timezone=optional_timezone)
else: else:
parsed = parse_time(date_to_parse=time_to_parse) parsed = parse_time(date_to_parse=time_to_parse)
if parsed.err: if parsed.err:
return await ctx.send(parsed.err_msg) return await ctx.send(parsed.err_msg)
parsed_date = parsed.parsed_time parsed_date: datetime | None = parsed.parsed_time
# Locales appropriate date and time representation. if parsed_date is None:
locale_time = parsed_date.strftime("%c") return await ctx.send(f"Failed to parse the date. ({time_to_parse})")
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
return await ctx.send(f"**String**: {time_to_parse}\n" # Locale`s appropriate date and time representation.
f"**Parsed date**: {parsed_date}\n" locale_time: str = parsed_date.strftime("%c")
f"**Formatted**: {run_date}\n" run_date: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
f"**Locale time**: {locale_time}\n") return await ctx.send(
f"**String**: {time_to_parse}\n"
f"**Parsed date**: {parsed_date}\n"
f"**Formatted**: {run_date}\n"
f"**Locale time**: {locale_time}\n",
)
@autodefer() @autodefer()
@base_command.subcommand(name="list", description="List, pause, unpause, and remove reminders.") @base_command.subcommand(
async def list_command(ctx: interactions.CommandContext): name="list",
description="List, pause, unpause, and remove reminders.",
)
async def list_command(ctx: interactions.CommandContext) -> Message | None:
"""List, pause, unpause, and remove reminders. """List, pause, unpause, and remove reminders.
Args: Args:
ctx: Context of the slash command. Contains the guild, author and message and more. ctx: Context of the slash command. Contains the guild, author and message and more.
""" """
pages: list[Page] = await create_pages(ctx)
pages = create_pages(ctx)
if not pages: if not pages:
return await ctx.send("No reminders found.", ephemeral=True) return await ctx.send("No reminders found.", ephemeral=True)
@ -187,7 +222,7 @@ async def list_command(ctx: interactions.CommandContext):
for page in pages: for page in pages:
return await ctx.send( return await ctx.send(
content="I haven't added support for buttons if there is only one reminder, " content="I haven't added support for buttons if there is only one reminder, "
"so you need to add another one to edit/delete this one 🙃", "so you need to add another one to edit/delete this one 🙃",
embeds=page.embeds, embeds=page.embeds,
) )
@ -202,23 +237,49 @@ async def list_command(ctx: interactions.CommandContext):
) )
await paginator.run() await paginator.run()
return None
@autodefer() @autodefer()
@base_command.subcommand(name="add", description="Set a reminder.") @base_command.subcommand(name="add", description="Set a reminder.")
@interactions.option(name="message_reason", description="The message I'm going to send you.", type=OptionType.STRING, required=True) @interactions.option(
@interactions.option(name="message_date", description="The date to send the message.", type=OptionType.STRING, required=True) name="message_reason",
@interactions.option(name="different_channel", description="The channel to send the message to.", type=OptionType.CHANNEL, required=False) description="The message I'm going to send you.",
@interactions.option(name="send_dm_to_user", description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.", type=OptionType.USER, required=False) # noqa type=OptionType.STRING,
@interactions.option(name="both_dm_and_channel", description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.", type=OptionType.BOOLEAN, required=False) # noqa required=True,
async def command_add( )
ctx: interactions.CommandContext, @interactions.option(
message_reason: str, name="message_date",
message_date: str, description="The date to send the message.",
different_channel: interactions.Channel | None = None, type=OptionType.STRING,
send_dm_to_user: interactions.User | None = None, required=True,
both_dm_and_channel: bool | None = None, )
): @interactions.option(
name="different_channel",
description="The channel to send the message to.",
type=OptionType.CHANNEL,
required=False,
)
@interactions.option(
name="send_dm_to_user",
description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.",
type=OptionType.USER,
required=False,
)
@interactions.option(
name="both_dm_and_channel",
description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.",
type=OptionType.BOOLEAN,
required=False,
)
async def command_add( # noqa: PLR0913
ctx: interactions.CommandContext,
message_reason: str,
message_date: str,
different_channel: interactions.Channel | None = None,
send_dm_to_user: interactions.User | None = None,
both_dm_and_channel: bool | None = None,
) -> Message | None:
"""Add a new reminder. You can add a date and message. """Add a new reminder. You can add a date and message.
Args: Args:
@ -230,23 +291,27 @@ async def command_add(
both_dm_and_channel: If we should send both a DM and a message to the channel. Works with different_channel. both_dm_and_channel: If we should send both a DM and a message to the channel. Works with different_channel.
""" """
# Parse the time/date we got from the command. # Parse the time/date we got from the command.
parsed = parse_time(date_to_parse=message_date) parsed: ParsedTime = parse_time(date_to_parse=message_date)
if parsed.err: if parsed.err:
return await ctx.send(parsed.err_msg) return await ctx.send(parsed.err_msg)
parsed_date = parsed.parsed_time parsed_date: datetime | None = parsed.parsed_time
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
if parsed_date is None:
return await ctx.send(f"Failed to parse the date. ({message_date})")
run_date: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
# If we should send the message to a different channel # If we should send the message to a different channel
channel_id = int(ctx.channel_id) channel_id = int(ctx.channel_id)
if different_channel: if different_channel:
channel_id = int(different_channel.id) channel_id = int(different_channel.id)
dm_message = "" dm_message: str = ""
where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)" where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)"
should_send_channel_reminder = True should_send_channel_reminder = True
try: try:
if send_dm_to_user: if send_dm_to_user:
dm_reminder = scheduler.add_job( dm_reminder: Job = scheduler.add_job(
send_to_user, send_to_user,
run_date=run_date, run_date=run_date,
kwargs={ kwargs={
@ -259,11 +324,15 @@ async def command_add(
if not both_dm_and_channel: if not both_dm_and_channel:
# If we should send the message to the channel too instead of just a DM. # If we should send the message to the channel too instead of just a DM.
should_send_channel_reminder = False should_send_channel_reminder = False
where_and_when = (f"I will send a DM to {send_dm_to_user.username} at:\n" where_and_when: str = (
f"**{run_date}** (in {calculate(dm_reminder)})\n") f"I will send a DM to {send_dm_to_user.username} at:\n"
f"**{run_date}** (in {calculate(dm_reminder)})\n"
)
if ctx.member is None:
return await ctx.send("Something went wrong when grabbing the member, are you in a guild?", ephemeral=True)
if should_send_channel_reminder: if should_send_channel_reminder:
reminder = scheduler.add_job( reminder: Job = scheduler.add_job(
send_to_discord, send_to_discord,
run_date=run_date, run_date=run_date,
kwargs={ kwargs={
@ -272,23 +341,20 @@ async def command_add(
"author_id": ctx.member.id, "author_id": ctx.member.id,
}, },
) )
where_and_when = (f"I will notify you in <#{channel_id}> {dm_message}at:\n" where_and_when = (
f"**{run_date}** (in {calculate(reminder)})\n") f"I will notify you in <#{channel_id}> {dm_message}at:\n**{run_date}** (in {calculate(reminder)})\n"
)
except ValueError as e: except ValueError as e:
await ctx.send(str(e), ephemeral=True) await ctx.send(str(e), ephemeral=True)
return return None
message = ( message: str = f"Hello {ctx.member.name}, {where_and_when}With the message:\n**{message_reason}**."
f"Hello {ctx.member.name}, "
f"{where_and_when}"
f"With the message:\n"
f"**{message_reason}**."
)
await ctx.send(message) await ctx.send(message)
return None
async def send_to_user(user_id: int, guild_id: int, message: str): async def send_to_user(user_id: int, guild_id: int, message: str) -> None:
"""Send a message to a user via DM. """Send a message to a user via DM.
Args: Args:
@ -296,7 +362,13 @@ async def send_to_user(user_id: int, guild_id: int, message: str):
guild_id: The guild ID to get the user from. guild_id: The guild ID to get the user from.
message: The message to send. message: The message to send.
""" """
member = await interactions.get(bot, interactions.Member, parent_id=guild_id, object_id=user_id, force="http") member: Member = await interactions.get(
bot,
interactions.Member,
parent_id=guild_id,
object_id=user_id,
force="http",
)
await member.send(message) await member.send(message)
@ -305,41 +377,121 @@ async def send_to_user(user_id: int, guild_id: int, message: str):
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.",
) )
@interactions.option(name="message_reason", description="The message I'm going to send you.", type=OptionType.STRING, required=True) @interactions.option(
@interactions.option(name="year", description="4-digit year. (Example: 2042)", type=OptionType.STRING, required=False) name="message_reason",
@interactions.option(name="month", description="Month. (1-12)", type=OptionType.STRING, required=False) description="The message I'm going to send you.",
@interactions.option(name="day", description="Day of month (1-31)", type=OptionType.STRING, required=False) type=OptionType.STRING,
@interactions.option(name="week", description="ISO week (1-53)", type=OptionType.STRING, required=False) required=True,
@interactions.option(name="day_of_week", description="Number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun).", type=OptionType.STRING, required=False) # noqa )
@interactions.option(name="hour", description="Hour (0-23)", type=OptionType.STRING, required=False) @interactions.option(
@interactions.option(name="minute", description="Minute (0-59)", type=OptionType.STRING, required=False) name="year",
@interactions.option(name="second", description="Second (0-59)", type=OptionType.STRING, required=False) description="4-digit year. (Example: 2042)",
@interactions.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) # noqa type=OptionType.STRING,
@interactions.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) # noqa required=False,
@interactions.option(name="timezone", description="Time zone to use for the date/time calculations (defaults to scheduler timezone)", type=OptionType.STRING, required=False) # noqa )
@interactions.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) # noqa @interactions.option(
@interactions.option(name="different_channel", description="Send the messages to a different channel.", type=OptionType.CHANNEL, required=False) # noqa name="month",
@interactions.option(name="send_dm_to_user", description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.", type=OptionType.USER, required=False) # noqa description="Month. (1-12)",
@interactions.option(name="both_dm_and_channel", description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.", type=OptionType.BOOLEAN, required=False) # noqa type=OptionType.STRING,
async def remind_cron( required=False,
ctx: interactions.CommandContext, )
message_reason: str, @interactions.option(
year: int | None = None, name="day",
month: int | None = None, description="Day of month (1-31)",
day: int | None = None, type=OptionType.STRING,
week: int | None = None, required=False,
day_of_week: str | None = None, )
hour: int | None = None, @interactions.option(
minute: int | None = None, name="week",
second: int | None = None, description="ISO week (1-53)",
start_date: str | None = None, type=OptionType.STRING,
end_date: str | None = None, required=False,
timezone: str | None = None, )
jitter: int | None = None, @interactions.option(
different_channel: interactions.Channel | None = None, name="day_of_week",
send_dm_to_user: interactions.User | None = None, description="Number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun).",
both_dm_and_channel: bool | None = None, type=OptionType.STRING,
): required=False,
)
@interactions.option(
name="hour",
description="Hour (0-23)",
type=OptionType.STRING,
required=False,
)
@interactions.option(
name="minute",
description="Minute (0-59)",
type=OptionType.STRING,
required=False,
)
@interactions.option(
name="second",
description="Second (0-59)",
type=OptionType.STRING,
required=False,
)
@interactions.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,
)
@interactions.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,
)
@interactions.option(
name="timezone",
description="Time zone to use for the date/time calculations (defaults to scheduler timezone)",
type=OptionType.STRING,
required=False,
)
@interactions.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,
)
@interactions.option(
name="different_channel",
description="Send the messages to a different channel.",
type=OptionType.CHANNEL,
required=False,
)
@interactions.option(
name="send_dm_to_user",
description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.",
type=OptionType.USER,
required=False,
)
@interactions.option(
name="both_dm_and_channel",
description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.",
type=OptionType.BOOLEAN,
required=False,
)
async def remind_cron( # noqa: PLR0913
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,
send_dm_to_user: interactions.User | None = None,
both_dm_and_channel: bool | None = None,
) -> None:
"""Create new cron job. Works like UNIX cron. """Create new cron job. Works like UNIX cron.
https://en.wikipedia.org/wiki/Cron https://en.wikipedia.org/wiki/Cron
@ -369,12 +521,12 @@ async def remind_cron(
if different_channel: if different_channel:
channel_id = int(different_channel.id) channel_id = int(different_channel.id)
dm_message = "" dm_message: str = ""
where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)" where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)"
should_send_channel_reminder = True should_send_channel_reminder = True
try: try:
if send_dm_to_user: if send_dm_to_user:
dm_reminder = scheduler.add_job( dm_reminder: Job = scheduler.add_job(
send_to_user, send_to_user,
"cron", "cron",
year=year, year=year,
@ -399,11 +551,15 @@ async def remind_cron(
if not both_dm_and_channel: if not both_dm_and_channel:
# If we should send the message to the channel too instead of just a DM. # If we should send the message to the channel too instead of just a DM.
should_send_channel_reminder = False should_send_channel_reminder = False
where_and_when = (f"I will send a DM to {send_dm_to_user.username} at:\n" where_and_when: str = (
f"First run in {calculate(dm_reminder)} with the message:\n") f"I will send a DM to {send_dm_to_user.username} at:\n"
f"First run in {calculate(dm_reminder)} with the message:\n"
)
if ctx.member is None:
await ctx.send("Failed to get member from context. Are you sure you're in a server?", ephemeral=True)
return
if should_send_channel_reminder: if should_send_channel_reminder:
job = scheduler.add_job( job: Job = scheduler.add_job(
send_to_discord, send_to_discord,
"cron", "cron",
year=year, year=year,
@ -424,52 +580,118 @@ async def remind_cron(
"author_id": ctx.member.id, "author_id": ctx.member.id,
}, },
) )
where_and_when = (f" I will send messages to <#{channel_id}>{dm_message}.\n" where_and_when = (
f"First run in {calculate(job)} with the message:\n") f" I will send messages to <#{channel_id}>{dm_message}.\n"
f"First run in {calculate(job)} with the message:\n"
)
except ValueError as e: except ValueError as e:
await ctx.send(str(e), ephemeral=True) await ctx.send(str(e), ephemeral=True)
return return
# 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: str = f"Hello {ctx.member.name}, {where_and_when} **{message_reason}**."
f"Hello {ctx.member.name}, "
f"{where_and_when}"
f"**{message_reason}**."
)
await ctx.send(message) await ctx.send(message)
@base_command.subcommand(name="interval", description="Schedules messages to be run periodically, on selected intervals.") @base_command.subcommand(
@interactions.option(name="message_reason", description="The message I'm going to send you.", type=OptionType.STRING, required=True) name="interval",
@interactions.option(name="weeks", description="Number of weeks to wait", type=OptionType.INTEGER, required=False) description="Schedules messages to be run periodically, on selected intervals.",
@interactions.option(name="days", description="Number of days to wait", type=OptionType.INTEGER, required=False) )
@interactions.option(name="hours", description="Number of hours to wait", type=OptionType.INTEGER, required=False) @interactions.option(
@interactions.option(name="minutes", description="Number of minutes to wait", type=OptionType.INTEGER, required=False) name="message_reason",
@interactions.option(name="seconds", description="Number of seconds to wait", type=OptionType.INTEGER, required=False) description="The message I'm going to send you.",
@interactions.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) # noqa type=OptionType.STRING,
@interactions.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) # noqa required=True,
@interactions.option(name="timezone", description="Time zone to use for the date/time calculations", type=OptionType.STRING, required=False) )
@interactions.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) # noqa @interactions.option(
@interactions.option(name="different_channel", description="Send the messages to a different channel.", type=OptionType.CHANNEL, required=False) name="weeks",
@interactions.option(name="send_dm_to_user", description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.", type=OptionType.USER, required=False) # noqa description="Number of weeks to wait",
@interactions.option(name="both_dm_and_channel", description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.", type=OptionType.BOOLEAN, required=False) # noqa type=OptionType.INTEGER,
async def remind_interval( required=False,
ctx: interactions.CommandContext, )
message_reason: str, @interactions.option(
weeks: int = 0, name="days",
days: int = 0, description="Number of days to wait",
hours: int = 0, type=OptionType.INTEGER,
minutes: int = 0, required=False,
seconds: int = 0, )
start_date: str | None = None, @interactions.option(
end_date: str | None = None, name="hours",
timezone: str | None = None, description="Number of hours to wait",
jitter: int | None = None, type=OptionType.INTEGER,
different_channel: interactions.Channel | None = None, required=False,
send_dm_to_user: interactions.User | None = None, )
both_dm_and_channel: bool | None = None, @interactions.option(
): name="minutes",
description="Number of minutes to wait",
type=OptionType.INTEGER,
required=False,
)
@interactions.option(
name="seconds",
description="Number of seconds to wait",
type=OptionType.INTEGER,
required=False,
)
@interactions.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,
)
@interactions.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,
)
@interactions.option(
name="timezone",
description="Time zone to use for the date/time calculations",
type=OptionType.STRING,
required=False,
)
@interactions.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,
)
@interactions.option(
name="different_channel",
description="Send the messages to a different channel.",
type=OptionType.CHANNEL,
required=False,
)
@interactions.option(
name="send_dm_to_user",
description="Send message to a user via DM instead of a channel. Set both_dm_and_channel to send both.",
type=OptionType.USER,
required=False,
)
@interactions.option(
name="both_dm_and_channel",
description="Send both DM and message to the channel, needs send_dm_to_user to be set if you want both.",
type=OptionType.BOOLEAN,
required=False,
)
async def remind_interval( # noqa: PLR0913
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,
send_dm_to_user: interactions.User | None = None,
both_dm_and_channel: bool | None = None,
) -> None:
"""Create a new reminder that triggers based on an interval. """Create a new reminder that triggers based on an interval.
Args: Args:
@ -493,12 +715,12 @@ async def remind_interval(
if different_channel: if different_channel:
channel_id = int(different_channel.id) channel_id = int(different_channel.id)
dm_message = "" dm_message: str = ""
where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)" where_and_when = "You should never see this message. Please report this to the bot owner if you do. :-)"
should_send_channel_reminder = True should_send_channel_reminder = True
try: try:
if send_dm_to_user: if send_dm_to_user:
dm_reminder = scheduler.add_job( dm_reminder: Job = scheduler.add_job(
send_to_user, send_to_user,
"interval", "interval",
weeks=weeks, weeks=weeks,
@ -520,10 +742,16 @@ async def remind_interval(
if not both_dm_and_channel: if not both_dm_and_channel:
# If we should send the message to the channel too instead of just a DM. # If we should send the message to the channel too instead of just a DM.
should_send_channel_reminder = False should_send_channel_reminder = False
where_and_when = (f"I will send a DM to {send_dm_to_user.username} at:\n" where_and_when: str = (
f"First run in {calculate(dm_reminder)} with the message:\n") f"I will send a DM to {send_dm_to_user.username} at:\n"
f"First run in {calculate(dm_reminder)} with the message:\n"
)
if ctx.member is None:
await ctx.send("Failed to get the member who sent the command.", ephemeral=True)
return
if should_send_channel_reminder: if should_send_channel_reminder:
job = scheduler.add_job( job: Job = scheduler.add_job(
send_to_discord, send_to_discord,
"interval", "interval",
weeks=weeks, weeks=weeks,
@ -541,37 +769,36 @@ async def remind_interval(
"author_id": ctx.member.id, "author_id": ctx.member.id,
}, },
) )
where_and_when = (f" I will send messages to <#{channel_id}>{dm_message}.\n" where_and_when = (
f"First run in {calculate(job)} with the message:\n") f" I will send messages to <#{channel_id}>{dm_message}.\n"
f"First run in {calculate(job)} with the message:"
)
except ValueError as e: except ValueError as e:
await ctx.send(str(e), ephemeral=True) await ctx.send(str(e), ephemeral=True)
return return
# 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: str = f"Hello {ctx.member.name}\n{where_and_when}\n**{message_reason}**."
f"Hello {ctx.member.name}\n"
f"{where_and_when}"
f"**{message_reason}**."
)
await ctx.send(message) await ctx.send(message)
def my_listener(event): def my_listener(event: JobExecutionEvent) -> None:
"""This gets called when something in APScheduler happens.""" """This gets called when something in APScheduler happens."""
if event.code == events.EVENT_JOB_MISSED: if event.code == events.EVENT_JOB_MISSED:
# TODO: Is it possible to get the message? # TODO: Is it possible to get the message?
scheduled_time = event.scheduled_run_time.strftime("%Y-%m-%d %H:%M:%S") scheduled_time: str = event.scheduled_run_time.strftime("%Y-%m-%d %H:%M:%S")
msg = f"Job {event.job_id} was missed! Was scheduled at {scheduled_time}" msg: str = f"Job {event.job_id} was missed! Was scheduled at {scheduled_time}"
send_webhook(message=msg) send_webhook(message=msg)
if event.exception: if event.exception:
send_webhook(f"discord-reminder-bot failed to send message to Discord\n" send_webhook(
f"{event}") f"discord-reminder-bot failed to send message to Discord\n{event}",
)
async def send_to_discord(channel_id: int, message: str, author_id: int): async def send_to_discord(channel_id: int, message: str, author_id: int) -> None:
"""Send a message to Discord. """Send a message to Discord.
Args: Args:
@ -579,26 +806,23 @@ async def send_to_discord(channel_id: int, message: str, author_id: int):
message: The message. message: The message.
author_id: User we should ping. author_id: User we should ping.
""" """
channel: Channel = await interactions.get(
channel = await interactions.get(
bot, bot,
interactions.Channel, interactions.Channel,
object_id=int(channel_id), object_id=channel_id,
force=interactions.Force.HTTP, force=interactions.Force.HTTP,
) )
await channel.send(f"<@{author_id}>\n{message}") await channel.send(f"<@{author_id}>\n{message}")
def start(): def start() -> None:
"""Start scheduler and log in to Discord.""" """Start scheduler and log in to Discord."""
# TODO: Add how many reminders are scheduled. # TODO: Add how many reminders are scheduled.
# TODO: Make backup of jobs.sqlite before running the bot. # TODO: Make backup of jobs.sqlite before running the bot.
logging.basicConfig(level=logging.getLevelName(log_level)) logging.basicConfig(level=logging.getLevelName(log_level))
logging.info( logging.info(
f"\nsqlite_location = {sqlite_location}\n" "\nsqlite_location = %s\nconfig_timezone = %s\nlog_level = %s" % (sqlite_location, config_timezone, log_level),
f"config_timezone = {config_timezone}\n"
f"log_level = {log_level}"
) )
scheduler.start() scheduler.start()
scheduler.add_listener(my_listener, EVENT_JOB_MISSED | EVENT_JOB_ERROR) scheduler.add_listener(my_listener, EVENT_JOB_MISSED | EVENT_JOB_ERROR)

View File

@ -9,8 +9,7 @@ from discord_reminder_bot.settings import config_timezone
@dataclasses.dataclass @dataclasses.dataclass
class ParsedTime: class ParsedTime:
""" """This is used when parsing a time or date from a string.
This is used when parsing a time or date from a string.
We use this when adding a job with /reminder add. We use this when adding a job with /reminder add.
@ -20,10 +19,11 @@ class ParsedTime:
err_msg: The error message. err_msg: The error message.
parsed_time: The parsed time we got from the string. parsed_time: The parsed time we got from the string.
""" """
date_to_parse: str = None
date_to_parse: str | None = None
err: bool = False err: bool = False
err_msg: str = "" err_msg: str = ""
parsed_time: datetime = None parsed_time: datetime | None = None
def parse_time(date_to_parse: str, timezone: str = config_timezone) -> ParsedTime: def parse_time(date_to_parse: str, timezone: str = config_timezone) -> ParsedTime:
@ -37,7 +37,7 @@ def parse_time(date_to_parse: str, timezone: str = config_timezone) -> ParsedTim
ParsedTime ParsedTime
""" """
try: try:
parsed_date = dateparser.parse( parsed_date: datetime | None = dateparser.parse(
f"{date_to_parse}", f"{date_to_parse}",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
@ -46,12 +46,25 @@ def parse_time(date_to_parse: str, timezone: str = config_timezone) -> ParsedTim
}, },
) )
except SettingValidationError as e: except SettingValidationError as e:
return ParsedTime(err=True, err_msg=f"Timezone is possible wrong?: {e}", date_to_parse=date_to_parse) return ParsedTime(
err=True,
err_msg=f"Timezone is possible wrong?: {e}",
date_to_parse=date_to_parse,
)
except ValueError as e: except ValueError as e:
return ParsedTime(err=True, err_msg=f"Failed to parse date. Unknown language: {e}", date_to_parse=date_to_parse) return ParsedTime(
err=True,
err_msg=f"Failed to parse date. Unknown language: {e}",
date_to_parse=date_to_parse,
)
except TypeError as e: except TypeError as e:
return ParsedTime(err=True, err_msg=f"{e}", date_to_parse=date_to_parse) return ParsedTime(err=True, err_msg=f"{e}", date_to_parse=date_to_parse)
if not parsed_date: return (
return ParsedTime(err=True, err_msg=f"Could not parse the date.", date_to_parse=date_to_parse) ParsedTime(parsed_time=parsed_date, date_to_parse=date_to_parse)
if parsed_date
return ParsedTime(parsed_time=parsed_date, date_to_parse=date_to_parse) else ParsedTime(
err=True,
err_msg="Could not parse the date.",
date_to_parse=date_to_parse,
)
)

View File

@ -6,18 +6,19 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(verbose=True) load_dotenv(verbose=True)
sqlite_location = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite") sqlite_location: str = os.getenv("SQLITE_LOCATION", default="/jobs.sqlite")
config_timezone = os.getenv("TIMEZONE", default="UTC") config_timezone: str = os.getenv("TIMEZONE", default="UTC")
bot_token = os.getenv("BOT_TOKEN", default="") bot_token: str = os.getenv("BOT_TOKEN", default="")
log_level = os.getenv("LOG_LEVEL", default="INFO") log_level: str = os.getenv("LOG_LEVEL", default="INFO")
webhook_url = os.getenv("WEBHOOK_URL", default="") webhook_url: str = os.getenv("WEBHOOK_URL", default="")
if not bot_token: if not bot_token:
raise ValueError("Missing bot token") err_msg = "Missing bot token"
raise ValueError(err_msg)
# Advanced Python Scheduler # Advanced Python Scheduler
jobstores = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")} jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url=f"sqlite://{sqlite_location}")}
job_defaults = {"coalesce": True} job_defaults: dict[str, bool] = {"coalesce": True}
scheduler = AsyncIOScheduler( scheduler = AsyncIOScheduler(
jobstores=jobstores, jobstores=jobstores,
timezone=pytz.timezone(config_timezone), timezone=pytz.timezone(config_timezone),

8
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. # This file is automatically @generated by Poetry and should not be changed by hand.
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -318,9 +318,9 @@ fasttext = ["fasttext"]
langdetect = ["langdetect"] langdetect = ["langdetect"]
[[package]] [[package]]
name = "dinteractions_Paginator" name = "dinteractions-Paginator"
version = "2.1.0" version = "2.1.0"
description = "" description = "Official interactions.py paginator"
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
@ -951,7 +951,7 @@ files = [
] ]
[package.dependencies] [package.dependencies]
greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and platform_machine == \"aarch64\" or python_version >= \"3\" and platform_machine == \"ppc64le\" or python_version >= \"3\" and platform_machine == \"x86_64\" or python_version >= \"3\" and platform_machine == \"amd64\" or python_version >= \"3\" and platform_machine == \"AMD64\" or python_version >= \"3\" and platform_machine == \"win32\" or python_version >= \"3\" and platform_machine == \"WIN32\""} greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
[package.extras] [package.extras]
aiomysql = ["aiomysql", "greenlet (!=0.4.17)"] aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]

View File

@ -39,4 +39,81 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.isort] [tool.isort]
profile = "black" profile = "black"
[tool.ruff]
line-length = 120
select = [
"E",
"F",
"B",
"W",
"C90",
"I",
"N",
"D",
"UP",
"YTT",
"ANN",
"S",
"BLE",
"FBT",
"A",
"COM",
"C4",
"DTZ",
"EM",
"EXE",
"ISC",
"ICN",
"G",
"INP",
"PIE",
"T20",
"PYI",
"PT",
"Q",
"RSE",
"RET",
"SLF",
"SIM",
"TID",
"TCH",
"ARG",
"PTH",
"ERA",
"PGH",
"PL",
"PLC",
"PLE",
"PLR",
"PLW",
"TRY",
"RUF",
]
ignore = [
"D100", # pydocstyle - missing docstring in public module
"D101", # pydocstyle - missing docstring in public class
"D102", # pydocstyle - missing docstring in public method
"D103", # pydocstyle - missing docstring in public function
"D104", # pydocstyle - missing docstring in public package
"D105", # pydocstyle - missing docstring in magic method
"D106", # pydocstyle - missing docstring in public nested class
"D107", # pydocstyle - missing docstring in __init__
"G002", # Allow % in logging
"UP031", # Allow % in logging
]
[tool.ruff.per-file-ignores]
"tests/*" = ["S101"]
[tool.ruff.pydocstyle]
convention = "google"
[tool.black]
line-length = 120
[tool.pytest.ini_options]
addopts = "-vvvvvv --exitfirst"
filterwarnings = ["ignore::DeprecationWarning:pkg_resources:121"]

View File

@ -1,10 +1,8 @@
"""Test discord-reminder-bot. from datetime import datetime
Jobs are stored in memory.
"""
import dateparser import dateparser
import pytz import pytz
from apscheduler.job import Job
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
@ -19,23 +17,25 @@ class TestCountdown:
runs at 00:00. runs at 00:00.
""" """
jobstores = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")} jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")}
job_defaults = {"coalesce": True} job_defaults: dict[str, bool] = {"coalesce": True}
scheduler = AsyncIOScheduler( scheduler = AsyncIOScheduler(
jobstores=jobstores, jobstores=jobstores,
timezone=pytz.timezone("Europe/Stockholm"), timezone=pytz.timezone("Europe/Stockholm"),
job_defaults=job_defaults, job_defaults=job_defaults,
) )
parsed_date = dateparser.parse( parsed_date: datetime | None = dateparser.parse(
"18 January 2040", "18 January 2040",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
"TO_TIMEZONE": "Europe/Stockholm", "TO_TIMEZONE": "Europe/Stockholm",
}, },
) )
run_date = parsed_date.strftime("%Y-%m-%d %H:%M:%S") assert parsed_date
job = scheduler.add_job(
run_date: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
job: Job = scheduler.add_job(
send_to_discord, send_to_discord,
run_date=run_date, run_date=run_date,
kwargs={ kwargs={
@ -45,7 +45,7 @@ class TestCountdown:
}, },
) )
timezone_date = dateparser.parse( timezone_date: datetime | None = dateparser.parse(
"00:00", "00:00",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
@ -53,8 +53,10 @@ class TestCountdown:
"TO_TIMEZONE": "Europe/Stockholm", "TO_TIMEZONE": "Europe/Stockholm",
}, },
) )
timezone_run_date = timezone_date.strftime("%Y-%m-%d %H:%M:%S")
timezone_job = scheduler.add_job( assert timezone_date
timezone_run_date: str = timezone_date.strftime("%Y-%m-%d %H:%M:%S")
timezone_job: Job = scheduler.add_job(
send_to_discord, send_to_discord,
run_date=timezone_run_date, run_date=timezone_run_date,
kwargs={ kwargs={
@ -64,7 +66,7 @@ class TestCountdown:
}, },
) )
timezone_date2 = dateparser.parse( timezone_date2: datetime | None = dateparser.parse(
"13:37", "13:37",
settings={ settings={
"PREFER_DATES_FROM": "future", "PREFER_DATES_FROM": "future",
@ -72,8 +74,10 @@ class TestCountdown:
"TO_TIMEZONE": "Europe/Stockholm", "TO_TIMEZONE": "Europe/Stockholm",
}, },
) )
timezone_run_date2 = timezone_date2.strftime("%Y-%m-%d %H:%M:%S")
timezone_job2 = scheduler.add_job( assert timezone_date2
timezone_run_date2: str = timezone_date2.strftime("%Y-%m-%d %H:%M:%S")
timezone_job2: Job = scheduler.add_job(
send_to_discord, send_to_discord,
run_date=timezone_run_date2, run_date=timezone_run_date2,
kwargs={ kwargs={
@ -83,21 +87,22 @@ class TestCountdown:
}, },
) )
# def test_countdown(self): def test_if_timezones_are_working(self) -> None: # noqa: ANN101
# """Check if calc_countdown returns days, hours and minutes.""" """Check if timezones are working.
# # 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)") Args:
# countdown = calculate(self.job) self: TestCountdown
# assert pattern.match(countdown) """
time_job: Job | None = self.scheduler.get_job(self.timezone_job.id)
assert time_job
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.hour == 0
assert time_job.trigger.run_date.minute == 0 assert time_job.trigger.run_date.minute == 0
assert time_job.trigger.run_date.second == 0 assert time_job.trigger.run_date.second == 0
time_job2 = self.scheduler.get_job(self.timezone_job2.id) time_job2: Job | None = self.scheduler.get_job(self.timezone_job2.id)
assert time_job2.trigger.run_date.hour == 13 assert time_job2
assert time_job2.trigger.run_date.minute == 37
assert time_job2.trigger.run_date.hour == 13 # noqa: PLR2004
assert time_job2.trigger.run_date.minute == 37 # noqa: PLR2004
assert time_job2.trigger.run_date.second == 0 assert time_job2.trigger.run_date.second == 0

View File

@ -1,14 +1,11 @@
from discord_reminder_bot import main from discord_reminder_bot import main
def test_if_send_to_discord_is_in_main(): def test_if_send_to_discord_is_in_main() -> None:
""" """send_to_discords needs to be in main for this program to work."""
send_to_discords needs to be in main for this program to work.
"""
assert hasattr(main, "send_to_discord") assert hasattr(main, "send_to_discord")
def test_if_send_to_user_is_in_main():
""" def test_if_send_to_user_is_in_main() -> None:
send_to_user needs to be in main for this program to work. """send_to_user needs to be in main for this program to work."""
""" assert hasattr(main, "send_to_user")
assert hasattr(main, "send_to_user")