diff --git a/assets/stopwatch.png b/assets/stopwatch.png new file mode 100644 index 0000000..728c9cd Binary files /dev/null and b/assets/stopwatch.png differ diff --git a/bot/extensions/reminder_cog.py b/bot/extensions/reminder_cog.py new file mode 100644 index 0000000..2f92dce --- /dev/null +++ b/bot/extensions/reminder_cog.py @@ -0,0 +1,146 @@ +import re +from datetime import datetime, timedelta, tzinfo +from discord import Embed, File +from discord.ext.commands import Cog, hybrid_command, Context +from pytz import timezone +from typing import Match +from io import BytesIO +from bot.grace import Grace + + +class ReminderCog( + Cog, + name="Reminder", + description="Handles reminder functionalities within the Discord bot.", +): + """A Discord Cog that manages user reminders.""" + + def __init__(self, bot: Grace): + self.bot: Grace = bot + self.jobs: list = [] + self.timezone: tzinfo = timezone("US/Eastern") + with open("assets/stopwatch.png", "rb") as f: + # Load the image bytes once during init is + # faster than reading from disk each time. + self.image_bytes: bytes = f.read() + + def cog_unload(self): + """Clean up any jobs this cog created.""" + for job in self.jobs: + self.bot.scheduler.remove_job(job.id) + + def __get_embed_image__(self) -> File: + """Returns the stopwatch image as a Discord File for embeds. + + The image is read from the in-memory bytes loaded during init. + + :return: A Discord File object containing the stopwatch image. + """ + return File(BytesIO(self.image_bytes), filename="stopwatch.png") + + def _build_embed(self, title: str, message: str, author: str) -> Embed: + """Builds a Discord embed with the given description. + + :param title: The title of the embed. + :param message: The description/message of the embed. + :param author: The author of the embed. + + :return: The constructed Embed object. + """ + embed = Embed( + color=self.bot.default_color, + title=f"**{title}**", + description=message, + timestamp=datetime.now(), + ) + + embed.set_author(name=author) + embed.set_thumbnail(url="attachment://stopwatch.png") + + return embed + + def _convert_to_timedelta(self, match: Match[str]) -> timedelta: + """Converts the parsed time format into a timedelta. + + :param match: The timer from the user containing amount and unit. + amount: The amount of time before the reminder. + unit: The unit of time (s=second, m=minute, h=hour, d=day). + + :return: The constructed timedelta for the reminder. + """ + amount, unit = match.groups() + amount = int(amount) + match unit: + case "s": + return timedelta(seconds=amount) + case "m": + return timedelta(minutes=amount) + case "h": + return timedelta(hours=amount) + case "d": + return timedelta(days=amount) + + @hybrid_command( + name="reminder", help="Set a reminder with a message", usage="{timer} {message}" + ) + async def reminder(self, ctx: Context, timer: str, *, message: str) -> None: + """ + Set a reminder for the user. + + :param ctx: Command context. + :param timer: Time after which to remind the user such as '10m', '2h', '1d'. + :param message: The reminder message. + """ + time_pattern = re.compile(r"(\d+)([smhd])") + match = time_pattern.fullmatch(timer) + if not match: + await ctx.send( + "Invalid time format! Use '10m' for 10 minutes,'2h' for 2 hours, etc." + ) + return + + reminder_delta = self._convert_to_timedelta(match) + reminder_time = datetime.now(self.timezone) + reminder_delta + + self.jobs.append( + self.bot.scheduler.add_job( + self.send_reminder, + "date", + run_date=reminder_time, + args=[ctx, message], + id=f"reminder_{ctx.author.id}_{datetime.now().timestamp()}", + ) + ) + + reminder_time = ( + ctx.message.created_at + reminder_delta # convert to the user timezone + ) + timestamp = int(reminder_time.timestamp()) + embed = self._build_embed( + "Reminder Set", + f"Reminder set for {timer} from now!\n" + f"You will be reminded on ****.", + ctx.author.display_name, + ) + embed.add_field( + name="", + value="_⚠️ Reminders are not saved and will be cleared on restart [read more](https://github.com/Code-Society-Lab/grace/issues/151)._", + inline=False, + ) + + await ctx.send(embed=embed, ephemeral=True, file=self.__get_embed_image__()) + + async def send_reminder(self, ctx: Context, message: str) -> None: + """Sends the reminder to the user when the scheduler triggers it. + + :param ctx: Command context. + :param message: The reminder message. + """ + embed = self._build_embed("Your Reminder", message, ctx.author.display_name) + await ctx.send( + f"<@{ctx.author.id}>", embed=embed, file=self.__get_embed_image__() + ) + + +async def setup(bot: Grace): + await bot.add_cog(ReminderCog(bot)) diff --git a/pyproject.toml b/pyproject.toml index 846f91c..34db263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "flake8", "mypy", "ruff", + "freezegun", ] [project.urls] diff --git a/tests/extensions/test_reminder_cog.py b/tests/extensions/test_reminder_cog.py new file mode 100644 index 0000000..f9c8bca --- /dev/null +++ b/tests/extensions/test_reminder_cog.py @@ -0,0 +1,152 @@ +import re +import pytest + +from datetime import timedelta, datetime +from freezegun import freeze_time +from unittest.mock import MagicMock, AsyncMock +from discord import Embed +from bot.extensions.reminder_cog import ReminderCog +from dateutil.tz import tzlocal + + +@pytest.fixture +def mock_bot(): + """Create a mock Discord bot instance.""" + bot = MagicMock() + bot.default_color = 0xFFFFFF + bot.app.config.get = MagicMock(return_value=None) + bot.scheduler = MagicMock() + return bot + + +@pytest.fixture +def reminder_cog(mock_bot): + """Instantiate the ReminderCog with a mock bot.""" + return ReminderCog(mock_bot) + + +@pytest.mark.parametrize( + "input_str, expected", + [ + ("10s", timedelta(seconds=10)), + ("5m", timedelta(minutes=5)), + ("2h", timedelta(hours=2)), + ("1d", timedelta(days=1)), + ("0s", timedelta(seconds=0)), + ], +) +def test_convert_to_timedelta_valid_formats(reminder_cog, input_str, expected): + """Ensure _convert_to_timedelta correctly parses valid time strings.""" + pattern = re.compile(r"(\d+)([smhd])") + match = pattern.fullmatch(input_str) + assert match, f"Regex failed to match valid timer format '{input_str}'" + + result = reminder_cog._convert_to_timedelta(match) + + assert isinstance(result, timedelta), f"Expected timedelta, got {type(result)}" + assert result == expected, f"For '{input_str}', expected {expected}, got {result}" + + +@pytest.mark.parametrize( + "input_str", + [ + "10minutes", + "5 hours", + "twoh", + "1 dayy", + "1 s", + "-5m", + "", + ], +) +def test_convert_to_timedelta_invalid_formats_expect_error(reminder_cog, input_str): + """Verify that invalid timer formats are rejected.""" + with pytest.raises(AttributeError): + reminder_cog._convert_to_timedelta(input_str) + + +def test_valid_embed(reminder_cog): + """Verify that embed is built correctly with valid input.""" + title = "Reminder" + message = "Hello World" + author = "Astra Al-Maarifa" + + with freeze_time("2025-02-20 12:00:01"): + result = reminder_cog._build_embed(title, message, author) + + assert isinstance(result, Embed), f"Expected Embed, got {type(result)}" + assert result.timestamp == datetime(2025, 2, 20, 12, 0, 1, tzinfo=tzlocal()) + assert result.title == f"**{title}**" + assert result.description == message + assert result.author.name == author + + assert result.thumbnail.url == "attachment://stopwatch.png" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "timer", + ["0s", "10s", "5m", "2h", "1d", "10000d"], +) +async def test_reminder_valid_input(reminder_cog, timer): + """Verify that reminder works with valid input.""" + with freeze_time("2025-02-20 12:00:01"): + ctx = AsyncMock() + ctx.author.display_name = "Astra Al-Maarifa" + + message = "Time for a break!" + await reminder_cog.reminder.callback( + reminder_cog, + ctx, + timer=timer, + message=message, + ) + + ctx.send.assert_awaited_once() + assert ctx.send.await_count == 1, "ctx.send should be awaited once" + + sent_Embed = ctx.send.call_args[1]["embed"] + assert isinstance(sent_Embed, Embed), f"Expected Embed, got {type(sent_Embed)}" + assert f"Reminder set for {timer} from now!" in sent_Embed.description + assert "You will be reminded on" in sent_Embed.description + assert sent_Embed.author.name == ctx.author.display_name + + reminder_cog.bot.scheduler.add_job.assert_called_once() + _, kwargs = reminder_cog.bot.scheduler.add_job.call_args + assert kwargs["args"][0] == ctx + assert kwargs["args"][1] == message + assert kwargs["id"].startswith( + f"reminder_{ctx.author.id}_{datetime.now().timestamp()}" + ) + assert kwargs["run_date"] >= datetime.now(tz=tzlocal()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "timer", + [ + "10seconds", + "5minutes", + "2hours", + "1days", + "-5m", + "abc", + "", + ], +) +async def test_reminder_invalid_input(reminder_cog, timer): + """Verify that reminder fails with invalid input.""" + ctx = AsyncMock() + + message = "Time for a break!" + await reminder_cog.reminder.callback( + reminder_cog, + ctx, + timer=timer, + message=message, + ) + + ctx.send.assert_awaited_once() + args, _ = ctx.send.call_args + assert "Invalid time format" in args[0] + reminder_cog.bot.scheduler.add_job.assert_not_called()