diff options
| -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"] | 
