diff options
| author | 2018-10-06 21:22:21 +0000 | |
|---|---|---|
| committer | 2018-10-06 21:22:21 +0000 | |
| commit | a9b280e819de555a3f52d0e0f37da002122eae93 (patch) | |
| tree | 4442cf1f0f88faa1b13d40413ce6242c5ca673ac | |
| parent | Merge branch 'better-moderation' into 'master' (diff) | |
| parent | Add Reminders cog. (diff) | |
Merge branch 'remind-command' into 'master'
Add Reminders cog.
See merge request python-discord/projects/bot!55
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 29 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 442 | ||||
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 22 | ||||
| -rw-r--r-- | bot/utils/time.py | 22 | ||||
| -rw-r--r-- | config-default.yml | 6 |
7 files changed, 503 insertions, 26 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index e4dbbfcde..602846ded 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -65,6 +65,7 @@ bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") +bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.snekbox") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 79e7f0f9f..4a0e4c0f4 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,5 +1,4 @@ import asyncio -import datetime import logging import textwrap from typing import Dict @@ -14,6 +13,8 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs from bot.converters import InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.scheduling import create_task +from bot.utils.time import parse_rfc1123, wait_until log = logging.getLogger(__name__) @@ -754,10 +755,7 @@ class Moderation: if infraction_id in self.expiration_tasks: return - task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop) - - # Silently ignore exceptions in a callback (handles the CancelledError nonsense) - task.add_done_callback(_silent_exception) + task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object)) self.expiration_tasks[infraction_id] = task @@ -787,12 +785,7 @@ class Moderation: # transform expiration to delay in seconds expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) - delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc) - delay_seconds = delay.total_seconds() - - if delay_seconds > 1.0: - log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds") - await asyncio.sleep(delay_seconds) + await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") await self._deactivate_infraction(infraction_object) @@ -855,20 +848,6 @@ class Moderation: # endregion -RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" - - -def parse_rfc1123(time_str): - return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) - - -def _silent_exception(future): - try: - future.exception() - except Exception: # noqa: S110 - pass - - def setup(bot): bot.add_cog(Moderation(bot)) log.info("Cog loaded: Moderation") diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py new file mode 100644 index 000000000..98d7942b3 --- /dev/null +++ b/bot/cogs/reminders.py @@ -0,0 +1,442 @@ +import asyncio +import datetime +import logging +import random +import textwrap + +from aiohttp import ClientResponseError +from dateutil.relativedelta import relativedelta +from discord import Colour, Embed +from discord.ext.commands import Bot, Context, group + +from bot.constants import ( + Channels, Icons, Keys, NEGATIVE_REPLIES, + POSITIVE_REPLIES, Roles, URLs +) +from bot.pagination import LinePaginator +from bot.utils.scheduling import create_task +from bot.utils.time import humanize_delta, parse_rfc1123, wait_until + +log = logging.getLogger(__name__) + +STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) +WHITELISTED_CHANNELS = (Channels.bot,) +MAXIMUM_REMINDERS = 5 + + +# The scheduling parts of this cog are pretty much directly copied +# from the moderation cog. I'll be working on making it more +# webscale:tm: as soon as possible, because this is a mess :D +class Reminders: + + def __init__(self, bot: Bot): + self.bot = bot + + self.headers = {"X-API-Key": Keys.site_api} + self.reminder_tasks = {} + + async def on_ready(self): + # Get all the current reminders for re-scheduling + response = await self.bot.http_session.get( + url=URLs.site_reminders_api, + headers=self.headers + ) + + response_data = await response.json() + + # Find the current time, timezone-aware. + now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + loop = asyncio.get_event_loop() + + for reminder in response_data["reminders"]: + remind_at = parse_rfc1123(reminder["remind_at"]) + + # If the reminder is already overdue ... + if remind_at < now: + late = relativedelta(now, remind_at) + await self.send_reminder(reminder, late) + + else: + self.schedule_reminder(loop, reminder) + + @staticmethod + async def _send_confirmation(ctx: Context, response: dict, on_success: str): + """ + Send an embed confirming whether or not a change was made successfully. + + :return: A Boolean value indicating whether it failed (True) or passed (False) + """ + + embed = Embed() + + if not response.get("success"): + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = response.get("error_message", "An unexpected error occurred.") + + log.warn(f"Unable to create/edit/delete a reminder. Response: {response}") + failed = True + + else: + embed.colour = Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success + + failed = False + + await ctx.send(embed=embed) + return failed + + def schedule_reminder(self, loop: asyncio.AbstractEventLoop, reminder): + """ + Schedule a reminder from the bot at the requested time. + + :param loop: the asyncio event loop + :param reminder: the data of the reminder. + """ + + # Avoid duplicate schedules, just in case. + reminder_id = reminder["id"] + if reminder_id in self.reminder_tasks: + return + + # Make a scheduled task and add it to the list + task: asyncio.Task = create_task(loop, self._scheduled_reminder(reminder)) + self.reminder_tasks[reminder_id] = task + + async def _scheduled_reminder(self, reminder): + """ + A coroutine which sends the reminder once the time is reached. + + :param reminder: the data of the reminder. + :return: + """ + + reminder_id = reminder["id"] + reminder_datetime = parse_rfc1123(reminder["remind_at"]) + + # Send the reminder message once the desired duration has passed + await wait_until(reminder_datetime) + await self.send_reminder(reminder) + + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder) + + # Now we can begone with it from our schedule list. + self.cancel_reminder(reminder_id) + + def cancel_reminder(self, reminder_id: str): + """ + Un-schedules a task to send a reminder. + + :param reminder_id: the ID of the reminder in question + """ + + task = self.reminder_tasks.get(reminder_id) + + if task is None: + log.warning(f"Failed to unschedule {reminder_id}: no task found.") + return + + task.cancel() + log.debug(f"Unscheduled {reminder_id}.") + del self.reminder_tasks[reminder_id] + + async def _delete_reminder(self, reminder_id: str): + """ + Delete a reminder from the database, given its ID. + + :param reminder_id: The ID of the reminder. + """ + + # The API requires a list, so let's give it one :) + json_data = { + "reminders": [ + reminder_id + ] + } + + await self.bot.http_session.delete( + url=URLs.site_reminders_api, + headers=self.headers, + json=json_data + ) + + # Now we can remove it from the schedule list + self.cancel_reminder(reminder_id) + + async def _reschedule_reminder(self, reminder): + """ + Reschedule a reminder object. + + :param reminder: The reminder to be rescheduled. + """ + + loop = asyncio.get_event_loop() + + self.cancel_reminder(reminder["id"]) + self.schedule_reminder(loop, reminder) + + async def send_reminder(self, reminder, late: relativedelta = None): + """ + Send the reminder. + + :param reminder: The data about the reminder. + :param late: How late the reminder is (if at all) + """ + + channel = self.bot.get_channel(int(reminder["channel_id"])) + user = self.bot.get_user(int(reminder["user_id"])) + + embed = Embed() + embed.colour = Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + embed.description = f"Here's your reminder: `{reminder['content']}`" + + if late: + embed.colour = Colour.red() + embed.set_author( + icon_url=Icons.remind_red, + name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + ) + + await channel.send( + content=user.mention, + embed=embed + ) + await self._delete_reminder(reminder["id"]) + + @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) + async def remind_group(self, ctx: Context, duration: str, *, content: str): + """ + Commands for managing your reminders. + """ + + await ctx.invoke(self.new_reminder, duration=duration, content=content) + + @remind_group.command(name="new", aliases=("add", "create")) + async def new_reminder(self, ctx: Context, duration: str, *, content: str): + """ + Set yourself a simple reminder. + """ + + embed = Embed() + + # Make sure the reminder should actually be made. + if ctx.author.top_role.id not in STAFF_ROLES: + + # If they don't have permission to set a reminder in this channel + if ctx.channel.id not in WHITELISTED_CHANNELS: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = "Sorry, you can't do that here!" + + return await ctx.send(embed=embed) + + # Get their current active reminders + response = await self.bot.http_session.get( + url=URLs.site_reminders_user_api.format(user_id=ctx.author.id), + headers=self.headers + ) + + active_reminders = await response.json() + + # Let's limit this, so we don't get 10 000 + # reminders from kip or something like that :P + if len(active_reminders) > MAXIMUM_REMINDERS: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = "You have too many active reminders!" + + return await ctx.send(embed=embed) + + # Now we can attempt to actually set the reminder. + try: + response = await self.bot.http_session.post( + url=URLs.site_reminders_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "duration": duration, + "content": content, + "channel_id": str(ctx.channel.id) + } + ) + + response_data = await response.json() + + # AFAIK only happens if the user enters, like, a quintillion weeks + except ClientResponseError: + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = ( + "An error occurred while adding your reminder to the database. " + "Did you enter a reasonable duration?" + ) + + log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.") + + return await ctx.send(embed=embed) + + # Confirm to the user whether or not it worked. + failed = await self._send_confirmation( + ctx, response_data, + on_success="Your reminder has been created successfully!" + ) + + # If it worked, schedule the reminder. + if not failed: + loop = asyncio.get_event_loop() + self.schedule_reminder(loop=loop, reminder=response_data["reminder"]) + + @remind_group.command(name="list") + async def list_reminders(self, ctx: Context): + """ + View a paginated embed of all reminders for your user. + """ + + # Get all the user's reminders from the database. + response = await self.bot.http_session.get( + url=URLs.site_reminders_user_api, + params={"user_id": str(ctx.author.id)}, + headers=self.headers + ) + + data = await response.json() + now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + + # Make a list of tuples so it can be sorted by time. + reminders = [ + (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"] + ] + + reminders.sort(key=lambda rem: rem[1]) + + lines = [] + + for index, (content, remind_at, friendly_id) in enumerate(reminders): + # Parse and humanize the time, make it pretty :D + remind_datetime = parse_rfc1123(remind_at) + time = humanize_delta(relativedelta(remind_datetime, now)) + + text = textwrap.dedent(f""" + **Reminder #{index}:** *expires in {time}* (ID: {friendly_id}) + {content} + """).strip() + + lines.append(text) + + embed = Embed() + embed.colour = Colour.blurple() + embed.title = f"Reminders for {ctx.author}" + + # Remind the user that they have no reminders :^) + if not lines: + embed.description = "No active reminders could be found." + return await ctx.send(embed=embed) + + # Construct the embed and paginate it. + embed.colour = Colour.blurple() + + await LinePaginator.paginate( + lines, + ctx, embed, + max_lines=3, + empty=True + ) + + @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) + async def edit_reminder_group(self, ctx: Context): + """ + Commands for modifying your current reminders. + """ + + await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") + + @edit_reminder_group.command(name="duration", aliases=("time",)) + async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str): + """ + Edit one of your reminders' duration. + """ + + # Send the request to update the reminder in the database + response = await self.bot.http_session.patch( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id, + "duration": duration + } + ) + + # Send a confirmation message to the channel + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been edited successfully!" + ) + + if not failed: + await self._reschedule_reminder(response_data["reminder"]) + + @edit_reminder_group.command(name="content", aliases=("reason",)) + async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str): + """ + Edit one of your reminders' content. + """ + + # Send the request to update the reminder in the database + response = await self.bot.http_session.patch( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id, + "content": content + } + ) + + # Send a confirmation message to the channel + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been edited successfully!" + ) + + if not failed: + await self._reschedule_reminder(response_data["reminder"]) + + @remind_group.command("delete", aliases=("remove",)) + async def delete_reminder(self, ctx: Context, friendly_id: str): + """ + Delete one of your active reminders. + """ + + # Send the request to delete the reminder from the database + response = await self.bot.http_session.delete( + url=URLs.site_reminders_user_api, + headers=self.headers, + json={ + "user_id": str(ctx.author.id), + "friendly_id": friendly_id + } + ) + + response_data = await response.json() + failed = await self._send_confirmation( + ctx, response_data, + on_success="That reminder has been deleted successfully!" + ) + + if not failed: + self.cancel_reminder(response_data["reminder_id"]) + + +def setup(bot: Bot): + bot.add_cog(Reminders(bot)) + log.info("Cog loaded: Reminders") diff --git a/bot/constants.py b/bot/constants.py index e718eb059..2433d15ef 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -289,6 +289,10 @@ class Icons(metaclass=YAMLGetter): pencil: str + remind_blurple: str + remind_green: str + remind_red: str + class CleanMessages(metaclass=YAMLGetter): section = "bot" @@ -342,7 +346,6 @@ class Roles(metaclass=YAMLGetter): muted: int owner: int verified: int - muted: int helpers: int @@ -397,6 +400,8 @@ class URLs(metaclass=YAMLGetter): site_logs_view: str site_names_api: str site_quiz_api: str + site_reminders_api: str + site_reminders_user_api: str site_schema: str site_settings_api: str site_special_api: str diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py new file mode 100644 index 000000000..f9b844046 --- /dev/null +++ b/bot/utils/scheduling.py @@ -0,0 +1,22 @@ +import asyncio +import contextlib + + +def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): + """ + Creates an asyncio.Task object from a coroutine or future object. + + :param loop: the asyncio event loop. + :param coro_or_future: the coroutine or future object to be scheduled. + """ + + task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) + + # Silently ignore exceptions in a callback (handles the CancelledError nonsense) + task.add_done_callback(_silent_exception) + return task + + +def _silent_exception(future): + with contextlib.suppress(Exception): + future.exception() diff --git a/bot/utils/time.py b/bot/utils/time.py index 77cef4670..8e5d4e1bd 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,7 +1,10 @@ +import asyncio import datetime from dateutil.relativedelta import relativedelta +RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" + def _stringify_time_unit(value: int, unit: str): """ @@ -89,3 +92,22 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max humanized = humanize_delta(delta, precision, max_units) return f"{humanized} ago" + + +def parse_rfc1123(time_str): + return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) + + +# Hey, this could actually be used in the off_topic_names and reddit cogs :) +async def wait_until(time: datetime.datetime): + """ + Wait until a given time. + + :param time: A datetime.datetime object to wait until. + """ + + delay = time - datetime.datetime.now(tz=datetime.timezone.utc) + delay_seconds = delay.total_seconds() + + if delay_seconds > 1.0: + await asyncio.sleep(delay_seconds) diff --git a/config-default.yml b/config-default.yml index ce7639186..7130eb540 100644 --- a/config-default.yml +++ b/config-default.yml @@ -72,6 +72,10 @@ style: pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" + remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" + remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" + remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" + guild: id: 267624335836053506 @@ -225,6 +229,8 @@ urls: site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"] site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"] + site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] + site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"] site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] |