Start refactoring bot, add /remind add command

This commit is contained in:
2025-01-03 15:03:47 +01:00
parent 2e30cafbac
commit c8b6dbfe41
19 changed files with 279 additions and 2774 deletions

View File

@ -19,4 +19,4 @@ LOG_LEVEL=INFO
# Webhook that discord-reminder-bot will send errors and information about missed reminders.
# https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
# Right click channel in Discord -> Intergrations -> Webhooks -> Create Webhook.
WEBHOOK_URL=https://discord.com/api/webhooks/582696524044304394/a3CMwZWchmHAXItB_lzSSRYBx0-AlPAHseJWqhHLfsAg_X4erac9-CeVeUDqPI1ac1vT
WEBHOOK_URL=https://discord.com/api/webhooks/582696524044304394/a3CMwZWchmHAXItB_lzSSRYBx0-AlPAHseJWqhHLfsAg_X4erac9-CeVeUDqPI1ac1vT

View File

@ -38,7 +38,7 @@ repos:
# An extremely fast Python linter and formatter.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
rev: v0.8.5
hooks:
- id: ruff-format
- id: ruff

12
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Start bot",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/discord_reminder_bot/main.py",
"console": "integratedTerminal",
}
]
}

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"cSpell.words": [
"apscheduler",
"asctime",
"audioop",
"dateparser",
"docstrings",
"dotenv",
"hikari",
"isort",
"jobstores",
"levelname",
"Lovinator",
"pycodestyle",
"pydocstyle",
"sqlalchemy",
"uvloop"
],
"python.analysis.typeCheckingMode": "standard"
}

View File

@ -1,67 +0,0 @@
from datetime import datetime, timedelta
import pytz
from apscheduler.job import Job
from apscheduler.triggers.date import DateTrigger
from discord_reminder_bot.settings import config_timezone
def calculate(job: Job) -> str:
"""Get trigger time from a reminder and calculate how many days, hours and minutes till trigger.
Days/Minutes will not be included if 0.
Args:
job: The job. Can be cron, interval or normal.
Returns:
Returns days, hours and minutes till the reminder. Returns "Couldn't calculate time" if no job is found.
"""
# TODO: This "breaks" when only seconds are left.
# If we use (in {calc_countdown(job)}) it will show (in )
trigger_time: datetime | None = job.trigger.run_date if type(job.trigger) is DateTrigger else job.next_run_time
# Get_job() returns None when it can't find a job with that ID.
if trigger_time is None:
# TODO: Change this to None and send this text where needed.
return "Couldn't calculate time"
# Get time and date the job will run and calculate how many days,
# hours and seconds.
return countdown(trigger_time)
def countdown(trigger_time: datetime) -> str:
"""Calculate days, hours and minutes to a date.
Args:
trigger_time: The date.
Returns:
A string with the days, hours and minutes.
"""
countdown_time: timedelta = trigger_time - datetime.now(tz=pytz.timezone(config_timezone))
days, hours, minutes = (
countdown_time.days,
countdown_time.seconds // 3600,
countdown_time.seconds // 60 % 60,
)
# Return seconds if only seconds are left.
if days == 0 and hours == 0 and minutes == 0:
seconds: int = countdown_time.seconds % 60
return f"{seconds} second" + ("s" if seconds != 1 else "")
# TODO: Explain this.
return ", ".join(
f"{x} {y}{'s' * (x != 1)}"
for x, y in (
(days, "day"),
(hours, "hour"),
(minutes, "minute"),
)
if x
)

View File

@ -1,311 +0,0 @@
from collections.abc import Generator
from typing import TYPE_CHECKING, Literal
import interactions
from apscheduler.job import Job
from apscheduler.schedulers.base import BaseScheduler
from apscheduler.triggers.date import DateTrigger
from interactions import (
ActionRow,
Button,
ButtonStyle,
Channel,
CommandContext,
ComponentContext,
Embed,
Message,
Modal,
TextInput,
)
from interactions.ext.paginator import Page, Paginator, RowPosition
from discord_reminder_bot.countdown import calculate
from discord_reminder_bot.settings import scheduler
if TYPE_CHECKING:
from datetime import datetime
max_message_length: Literal[1010] = 1010
max_title_length: Literal[90] = 90
def _get_trigger_text(job: Job) -> str:
"""Get trigger time from a reminder and calculate how many days, hours and minutes till trigger.
Args:
job: The job. Can be cron, interval or normal.
Returns:
str: The trigger time and countdown till trigger. If the job is paused, it will return "_Paused_".
"""
# TODO: Add support for cron jobs and interval jobs
trigger_time: datetime | None = job.trigger.run_date if type(job.trigger) is DateTrigger else job.next_run_time
return "_Paused_" if trigger_time is None else f'{trigger_time.strftime("%Y-%m-%d %H:%M")} (in {calculate(job)})'
def _make_button(label: str, style: ButtonStyle) -> Button:
"""Make a button.
Args:
label: The label of the button.
style: The style of the button.
Returns:
Button: The button.
"""
return interactions.Button(
label=label,
style=style,
custom_id=label.lower(),
)
def _get_pause_or_unpause_button(job: Job) -> Button | None:
"""Get pause or unpause button.
If the job is paused, it will return the unpause button.
If the job is not paused, it will return the pause button.
If the job is not a cron or interval job, it will return None.
Args:
job: The job. Can be cron, interval or normal.
Returns:
Button | None: The pause or unpause button. If the job is not a cron or interval job, it will return None.
"""
if type(job.trigger) is not DateTrigger:
pause_button: Button = _make_button("Pause", interactions.ButtonStyle.PRIMARY)
unpause_button: Button = _make_button("Unpause", interactions.ButtonStyle.PRIMARY)
if not hasattr(job, "next_run_time"):
return pause_button
return unpause_button if job.next_run_time is None else pause_button
return None
def _get_row_of_buttons(job: Job) -> ActionRow:
"""Get components(buttons) for a page in /reminder list.
These buttons are below the embed.
Args:
job: The job. Can be cron, interval or normal.
Returns:
ActionRow: A row of buttons.
"""
components: list[Button] = [
_make_button("Edit", interactions.ButtonStyle.PRIMARY),
_make_button("Remove", interactions.ButtonStyle.DANGER),
]
# Add pause/unpause button as the second button if it's a cron or interval job
pause_or_unpause_button: Button | None = _get_pause_or_unpause_button(job=job)
if pause_or_unpause_button is not None:
components.insert(1, pause_or_unpause_button)
# TODO: Should fix the type error
return ActionRow(components=components) # type: ignore # noqa: PGH003
def _get_pages(job: Job, channel: Channel, ctx: CommandContext) -> Generator[Page, None, None]:
"""Get pages for a reminder.
Args:
job: The job. Can be cron, interval or normal.
channel: Check if the job kwargs channel ID is the same as the channel ID we looped through.
ctx: The context. Used to get the guild ID.
Yields:
Generator[Page, None, None]: A page.
"""
# Get channel ID and guild ID from job kwargs
channel_id: int = job.kwargs.get("channel_id")
guild_id: int = job.kwargs.get("guild_id")
if int(channel.id) == channel_id or ctx.guild_id == guild_id:
message: str = job.kwargs.get("message")
# If message is longer than 1000 characters, truncate it
message = f"{message[:1000]}..." if len(message) > max_message_length else message
# Create embed for the singular page
embed: Embed = interactions.Embed(
title=f"{job.id}", # Example: 593dcc18aab748faa571017454669eae
fields=[
interactions.EmbedField(
name="**Channel:**",
value=f"#{channel.name}", # Example: #general
),
interactions.EmbedField(
name="**Message:**",
value=f"{message}", # Example: Don't forget to feed the cat!
),
interactions.EmbedField(
name="**Trigger:**",
value=_get_trigger_text(job=job), # Example: 2023-08-24 00:06 (in 157 days, 23 hours, 49 minutes)
),
],
)
# Truncate title if it's longer than 90 characters
# This is the text that shows up in the dropdown menu
# Example: 2: Don't forget to feed the cat!
dropdown_title: str = f"{message[:87]}..." if len(message) > max_title_length else message
# Create a page and return it
yield Page(
embeds=embed,
title=dropdown_title,
components=_get_row_of_buttons(job),
callback=_callback,
position=RowPosition.BOTTOM,
)
def _remove_job(job: Job) -> str:
"""Remove a job.
Args:
job: The job to remove.
Returns:
str: The message to send to Discord.
"""
# TODO: Check if job exists before removing it?
# TODO: Add button to undo the removal?
channel_id: int = job.kwargs.get("channel_id")
old_message: str = job.kwargs.get("message")
try:
trigger_time: datetime | str = job.trigger.run_date
except AttributeError:
trigger_time = "N/A"
scheduler.remove_job(job.id)
return f"Job {job.id} removed.\n**Message:** {old_message}\n**Channel:** {channel_id}\n**Time:** {trigger_time}"
def _unpause_job(job: Job, custom_scheduler: BaseScheduler = scheduler) -> str:
"""Unpause a job.
Args:
job: The job to unpause.
custom_scheduler: The scheduler to use. Defaults to the global scheduler.
Returns:
str: The message to send to Discord.
"""
# TODO: Should we check if the job is paused before unpause it?
custom_scheduler.resume_job(job.id)
return f"Job {job.id} unpaused."
def _pause_job(job: Job, custom_scheduler: BaseScheduler = scheduler) -> str:
"""Pause a job.
Args:
job: The job to pause.
custom_scheduler: The scheduler to use. Defaults to the global scheduler.
Returns:
str: The message to send to Discord.
"""
# TODO: Should we check if the job is unpaused before unpause it?
custom_scheduler.pause_job(job.id)
return f"Job {job.id} paused."
async def _callback(self: Paginator, ctx: ComponentContext) -> Message | None:
"""Callback for the paginator."""
# TODO: Create a test for this
if self.component_ctx is None or 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:
return await ctx.send("Job not found.", ephemeral=True)
job.kwargs.get("channel_id")
old_message: str = job.kwargs.get("message")
components: list[TextInput] = [
interactions.TextInput(
style=interactions.TextStyleType.PARAGRAPH,
label="New message",
custom_id="new_message",
value=old_message,
required=False,
),
]
job_type = "cron/interval"
if type(job.trigger) is DateTrigger:
# Get trigger time for normal reminders
trigger_time: datetime | None = job.trigger.run_date
job_type: str = "normal"
components.append(
interactions.TextInput(
style=interactions.TextStyleType.SHORT,
label="New date, Can be human readable or ISO8601",
custom_id="new_date",
value=str(trigger_time),
required=False,
),
)
# Check what button was clicked and call the correct function
msg = "Something went wrong. I don't know what you clicked."
if ctx.custom_id == "edit":
# TODO: Add buttons to increase/decrease hour
modal: Modal = interactions.Modal(
title=f"Edit {job_type} reminder.",
custom_id="edit_modal",
components=components, # type: ignore # noqa: PGH003
)
await ctx.popup(modal)
msg = f"You modified {job_id}"
elif ctx.custom_id == "pause":
msg: str = _pause_job(job)
elif ctx.custom_id == "unpause":
msg: str = _unpause_job(job)
elif ctx.custom_id == "remove":
msg: str = _remove_job(job)
return await ctx.send(msg, ephemeral=True)
async def create_pages(ctx: CommandContext) -> list[Page]:
"""Create pages for the paginator.
Args:
ctx: The context of the command.
Returns:
list[Page]: A list of pages.
"""
# TODO: Add tests for this
pages: list[Page] = []
jobs: list[Job] = scheduler.get_jobs()
for job in jobs:
# Check if we're in a server
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 []
# Check if we're in a channel
if ctx.guild.channels is None:
await ctx.send("I can't find the channel you're in.", ephemeral=True)
return []
# 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:
# Add a page for each reminder
pages.extend(iter(_get_pages(job=job, channel=channel, ctx=ctx)))
return pages

File diff suppressed because it is too large Load Diff

View File

@ -1,70 +0,0 @@
import dataclasses
from datetime import datetime
import dateparser
from dateparser.conf import SettingValidationError
from discord_reminder_bot.settings import config_timezone
@dataclasses.dataclass
class ParsedTime:
"""This is used when parsing a time or date from a string.
We use this when adding a job with /reminder add.
Attributes:
date_to_parse: The string we parsed the time from.
err: True if an error was raised when parsing the time.
err_msg: The error message.
parsed_time: The parsed time we got from the string.
"""
date_to_parse: str | None = None
err: bool = False
err_msg: str = ""
parsed_time: datetime | None = None
def parse_time(date_to_parse: str, timezone: str = config_timezone) -> ParsedTime:
"""Parse the datetime from a string.
Args:
date_to_parse: The string we want to parse.
timezone: The timezone to use when parsing. This will be used when typing things like "22:00".
Returns:
ParsedTime
"""
try:
parsed_date: datetime | None = dateparser.parse(
f"{date_to_parse}",
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": f"{timezone}",
"TO_TIMEZONE": f"{timezone}",
},
)
except SettingValidationError as e:
return ParsedTime(
err=True,
err_msg=f"Timezone is possible wrong?: {e}",
date_to_parse=date_to_parse,
)
except ValueError as e:
return ParsedTime(
err=True,
err_msg=f"Failed to parse date. Unknown language: {e}",
date_to_parse=date_to_parse,
)
except TypeError as e:
return ParsedTime(err=True, err_msg=f"{e}", date_to_parse=date_to_parse)
return (
ParsedTime(parsed_time=parsed_date, date_to_parse=date_to_parse)
if parsed_date
else ParsedTime(
err=True,
err_msg="Could not parse the date.",
date_to_parse=date_to_parse,
)
)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import os
import pytz

0
interactions/__init__.py Normal file
View File

View File

View File

View File

@ -0,0 +1,25 @@
"""This file is only here so we can unpickle the old jobs."""
from __future__ import annotations
class Snowflake:
"""A class to represent a Discord snowflake."""
__slots__: list[str] = ["_snowflake"]
def __init__(self, snowflake: int | str | Snowflake) -> None:
"""Initialize the Snowflake object.
Args:
snowflake (int | str | Snowflake): The snowflake to store.
"""
self._snowflake = str(snowflake)
def __str__(self) -> str:
"""Return the snowflake as a string."""
return self._snowflake
def __int__(self) -> int:
"""Return the snowflake as an integer."""
return int(self._snowflake)

1133
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ dependencies = [
"apscheduler<4.0.0",
"discord-py",
"audioop-lts",
"discord-webhook",
]
[dependency-groups]
@ -45,13 +46,13 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
# https://docs.astral.sh/ruff/linter/
preview = true
# Enable all rules
line-length = 120
lint.select = ["ALL"]
lint.pydocstyle.convention = "google"
lint.isort.required-imports = ["from __future__ import annotations"]
lint.pycodestyle.ignore-overlong-task-comments = true
# Ignore some rules
lint.ignore = [
"CPY001", # Checks for the absence of copyright notices within Python files.
"D100", # Checks for undocumented public module definitions.
@ -78,24 +79,13 @@ lint.ignore = [
"W191", # Checks for indentation that uses tabs.
]
# https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html
lint.pydocstyle.convention = "google"
# Add "from __future__ import annotations" to all files
lint.isort.required-imports = ["from __future__ import annotations"]
lint.pycodestyle.ignore-overlong-task-comments = true
# Default is 88 characters
line-length = 120
[tool.ruff.format]
# https://docs.astral.sh/ruff/formatter/
docstring-code-format = true
docstring-code-line-length = 20
[tool.ruff.lint.per-file-ignores]
"**/*_test.py" = [
"**/test_*.py" = [
"ARG", # Unused function args -> fixtures nevertheless are functionally relevant...
"FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
"PLR2004", # Magic value used in comparison, ...
@ -103,13 +93,10 @@ docstring-code-line-length = 20
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
]
# https://pytest-django.readthedocs.io/en/latest/
[tool.pytest.ini_options]
# Enable logging in the console.
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
# Only test files with the following suffixes.
python_files = "test_*.py *_test.py *_tests.py"

View File

@ -1,108 +0,0 @@
from datetime import datetime
import dateparser
import pytz
from apscheduler.job import Job
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from discord_reminder_bot.main import send_to_discord
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 and one that
runs at 00:00.
"""
jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")}
job_defaults: dict[str, bool] = {"coalesce": True}
scheduler = AsyncIOScheduler(
jobstores=jobstores,
timezone=pytz.timezone("Europe/Stockholm"),
job_defaults=job_defaults,
)
parsed_date: datetime | None = dateparser.parse(
"18 January 2040",
settings={
"PREFER_DATES_FROM": "future",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
assert parsed_date
run_date: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
job: Job = scheduler.add_job(
send_to_discord,
run_date=run_date,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest",
"author_id": 126462229892694018,
},
)
timezone_date: datetime | None = dateparser.parse(
"00:00",
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": "Europe/Stockholm",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
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,
run_date=timezone_run_date,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest at 00:00",
"author_id": 126462229892694018,
},
)
timezone_date2: datetime | None = dateparser.parse(
"13:37",
settings={
"PREFER_DATES_FROM": "future",
"TIMEZONE": "Europe/Stockholm",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
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,
run_date=timezone_run_date2,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest at 13:37",
"author_id": 126462229892694018,
},
)
def test_if_timezones_are_working(self) -> None: # noqa: ANN101
"""Check if timezones are working.
Args:
self: TestCountdown
"""
time_job: Job | None = self.scheduler.get_job(self.timezone_job.id)
assert time_job
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: Job | None = self.scheduler.get_job(self.timezone_job2.id)
assert time_job2
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

View File

@ -1,192 +0,0 @@
import re
from datetime import datetime
from typing import TYPE_CHECKING
import dateparser
import interactions
import pytz
from apscheduler.job import Job
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from interactions.ext.paginator import Page
from discord_reminder_bot.create_pages import (
_get_pages,
_get_pause_or_unpause_button,
_get_row_of_buttons,
_get_trigger_text,
_make_button,
_pause_job,
_unpause_job,
)
from discord_reminder_bot.main import send_to_discord
if TYPE_CHECKING:
from collections.abc import Generator
def _test_pause_unpause_button(job: Job, button_label: str) -> None:
button2: interactions.Button | None = _get_pause_or_unpause_button(job)
assert button2
assert button2.label == button_label
assert button2.style == interactions.ButtonStyle.PRIMARY
assert button2.type == interactions.ComponentType.BUTTON
assert button2.emoji is None
assert button2.custom_id == button_label.lower()
assert button2.url is None
assert button2.disabled is None
class TestCountdown:
jobstores: dict[str, SQLAlchemyJobStore] = {"default": SQLAlchemyJobStore(url="sqlite:///:memory")}
job_defaults: dict[str, bool] = {"coalesce": True}
scheduler = AsyncIOScheduler(
jobstores=jobstores,
timezone=pytz.timezone("Europe/Stockholm"),
job_defaults=job_defaults,
)
parsed_date: datetime | None = dateparser.parse(
"18 January 2040",
settings={
"PREFER_DATES_FROM": "future",
"TO_TIMEZONE": "Europe/Stockholm",
},
)
assert parsed_date
run_date: str = parsed_date.strftime("%Y-%m-%d %H:%M:%S")
normal_job: Job = scheduler.add_job(
send_to_discord,
run_date=run_date,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest",
"author_id": 126462229892694018,
},
)
cron_job: Job = scheduler.add_job(
send_to_discord,
"cron",
minute="0",
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest",
"author_id": 126462229892694018,
},
)
interval_job: Job = scheduler.add_job(
send_to_discord,
"interval",
minutes=1,
kwargs={
"channel_id": 865712621109772329,
"message": "Running PyTest",
"author_id": 126462229892694018,
},
)
def test_get_trigger_text(self) -> None: # noqa: ANN101
# FIXME: This try except train should be replaced with a better solution lol
trigger_text: str = _get_trigger_text(self.normal_job)
try:
regex: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ hours, \d+ minutes\)"
assert re.match(regex, trigger_text)
except AssertionError:
try:
regex2: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ minutes\)"
assert re.match(regex2, trigger_text)
except AssertionError:
regex3: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ hours\)"
assert re.match(regex3, trigger_text)
def test_make_button(self) -> None: # noqa: ANN101
button_name: str = "Test"
button: interactions.Button = _make_button(label=button_name, style=interactions.ButtonStyle.PRIMARY)
assert button.label == button_name
assert button.style == interactions.ButtonStyle.PRIMARY
assert button.custom_id == button_name.lower()
assert button.disabled is None
assert button.emoji is None
def test_get_pause_or_unpause_button(self) -> None: # noqa: ANN101
button: interactions.Button | None = _get_pause_or_unpause_button(self.normal_job)
assert button is None
_test_pause_unpause_button(self.cron_job, "Pause")
self.cron_job.pause()
_test_pause_unpause_button(self.cron_job, "Unpause")
self.cron_job.resume()
_test_pause_unpause_button(self.interval_job, "Pause")
self.interval_job.pause()
_test_pause_unpause_button(self.interval_job, "Unpause")
self.interval_job.resume()
def test_get_row_of_buttons(self) -> None: # noqa: ANN101
row: interactions.ActionRow = _get_row_of_buttons(self.normal_job)
assert row
assert row.components
# A normal job should have 2 buttons, edit and delete
assert len(row.components) == 2 # noqa: PLR2004
row2: interactions.ActionRow = _get_row_of_buttons(self.cron_job)
assert row2
assert row2.components
# A cron job should have 3 buttons, edit, delete and pause/unpause
assert len(row2.components) == 3 # noqa: PLR2004
# A cron job should have 3 buttons, edit, delete and pause/unpause
assert len(row2.components) == 3 # noqa: PLR2004
def test_get_pages(self) -> None: # noqa: ANN101
ctx = None # TODO: We should check ctx as well and not only channel id
channel: interactions.Channel = interactions.Channel(id=interactions.Snowflake(865712621109772329))
pages: Generator[Page, None, None] = _get_pages(job=self.normal_job, channel=channel, ctx=ctx) # type: ignore # noqa: PGH003, E501
assert pages
for page in pages:
assert page
assert page.title == "Running PyTest"
assert page.components
assert page.embeds
assert page.embeds.fields is not None # type: ignore # noqa: PGH003
assert page.embeds.fields[0].name == "**Channel:**" # type: ignore # noqa: PGH003
assert page.embeds.fields[0].value == "#" # type: ignore # noqa: PGH003
assert page.embeds.fields[1].name == "**Message:**" # type: ignore # noqa: PGH003
assert page.embeds.fields[1].value == "Running PyTest" # type: ignore # noqa: PGH003
assert page.embeds.fields[2].name == "**Trigger:**" # type: ignore # noqa: PGH003
trigger_text: str = page.embeds.fields[2].value # type: ignore # noqa: PGH003
# FIXME: This try except train should be replaced with a better solution lol
try:
regex: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ hours, \d+ minutes\)"
assert re.match(regex, trigger_text)
except AssertionError:
try:
regex2: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ minutes\)"
assert re.match(regex2, trigger_text)
except AssertionError:
regex3: str = r"2040-01-18 \d+:00 \(in \d+ days, \d+ hours\)"
assert re.match(regex3, trigger_text)
# Check if type is Page
assert isinstance(page, Page)
def test_pause_job(self) -> None: # noqa: ANN101
assert _pause_job(self.interval_job, self.scheduler) == f"Job {self.interval_job.id} paused."
assert _pause_job(self.cron_job, self.scheduler) == f"Job {self.cron_job.id} paused."
assert _pause_job(self.normal_job, self.scheduler) == f"Job {self.normal_job.id} paused."
def test_unpause_job(self) -> None: # noqa: ANN101
assert _unpause_job(self.interval_job, self.scheduler) == f"Job {self.interval_job.id} unpaused."
assert _unpause_job(self.cron_job, self.scheduler) == f"Job {self.cron_job.id} unpaused."
assert _unpause_job(self.normal_job, self.scheduler) == f"Job {self.normal_job.id} unpaused."

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from discord_reminder_bot import main

View File

@ -1,68 +0,0 @@
from datetime import datetime
import tzlocal
from discord_reminder_bot.parse import ParsedTime, parse_time
def test_parse_time() -> None:
"""Test the parse_time function."""
parsed_time: ParsedTime = parse_time("18 January 2040")
assert parsed_time.err is False
assert not parsed_time.err_msg
assert parsed_time.date_to_parse == "18 January 2040"
assert parsed_time.parsed_time
assert parsed_time.parsed_time.strftime("%Y-%m-%d %H:%M:%S") == "2040-01-18 00:00:00"
parsed_time: ParsedTime = parse_time("18 January 2040 12:00")
assert parsed_time.err is False
assert not parsed_time.err_msg
assert parsed_time.date_to_parse == "18 January 2040 12:00"
assert parsed_time.parsed_time
assert parsed_time.parsed_time.strftime("%Y-%m-%d %H:%M:%S") == "2040-01-18 12:00:00"
parsed_time: ParsedTime = parse_time("18 January 2040 12:00:00")
assert parsed_time.err is False
assert not parsed_time.err_msg
assert parsed_time.date_to_parse == "18 January 2040 12:00:00"
assert parsed_time.parsed_time
assert parsed_time.parsed_time.strftime("%Y-%m-%d %H:%M:%S") == "2040-01-18 12:00:00"
parsed_time: ParsedTime = parse_time("18 January 2040 12:00:00 UTC")
assert parsed_time.err is False
assert not parsed_time.err_msg
assert parsed_time.date_to_parse == "18 January 2040 12:00:00 UTC"
assert parsed_time.parsed_time
assert parsed_time.parsed_time.strftime("%Y-%m-%d %H:%M:%S") == "2040-01-18 13:00:00"
parsed_time: ParsedTime = parse_time("18 January 2040 12:00:00 Europe/Stockholm")
assert parsed_time.err is True
assert parsed_time.err_msg == "Could not parse the date."
assert parsed_time.date_to_parse == "18 January 2040 12:00:00 Europe/Stockholm"
assert parsed_time.parsed_time is None
def test_ParsedTime() -> None: # noqa: N802
"""Test the ParsedTime class."""
parsed_time: ParsedTime = ParsedTime(
err=False,
err_msg="",
date_to_parse="18 January 2040",
parsed_time=datetime(2040, 1, 18, 0, 0, 0, tzinfo=tzlocal.get_localzone()),
)
assert parsed_time.err is False
assert not parsed_time.err_msg
assert parsed_time.date_to_parse == "18 January 2040"
assert parsed_time.parsed_time
assert parsed_time.parsed_time.strftime("%Y-%m-%d %H:%M:%S") == "2040-01-18 00:00:00"
parsed_time: ParsedTime = ParsedTime(
err=True,
err_msg="Could not parse the date.",
date_to_parse="18 January 2040 12:00:00 Europe/Stockholm",
parsed_time=None,
)
assert parsed_time.err is True
assert parsed_time.err_msg == "Could not parse the date."
assert parsed_time.date_to_parse == "18 January 2040 12:00:00 Europe/Stockholm"
assert parsed_time.parsed_time is None