diff options
-rw-r--r-- | bot/exts/utils/reminders.py | 492 |
1 files changed, 0 insertions, 492 deletions
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py deleted file mode 100644 index 289d00356..000000000 --- a/bot/exts/utils/reminders.py +++ /dev/null @@ -1,492 +0,0 @@ -import random -import textwrap -import typing as t -from datetime import datetime, timezone -from operator import itemgetter - -import discord -from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES -from bot.converters import Duration, UnambiguousUser -from bot.log import get_logger -from bot.pagination import LinePaginator -from bot.utils import scheduling, time -from bot.utils.checks import has_any_role_check, has_no_roles_check -from bot.utils.lock import lock_arg -from bot.utils.members import get_or_fetch_member -from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler - -log = get_logger(__name__) - -LOCK_NAMESPACE = "reminder" -WHITELISTED_CHANNELS = Guild.reminder_whitelist -MAXIMUM_REMINDERS = 5 - -Mentionable = t.Union[discord.Member, discord.Role] -ReminderMention = t.Union[UnambiguousUser, discord.Role] - - -class Reminders(Cog): - """Provide in-channel reminder functionality.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - - scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - async def reschedule_reminders(self) -> None: - """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_guild_available() - response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} - ) - - now = datetime.now(timezone.utc) - - for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder) - if not is_valid: - continue - - remind_at = isoparse(reminder['expiration']) - - # If the reminder is already overdue ... - if remind_at < now: - await self.send_reminder(reminder, remind_at) - else: - self.schedule_reminder(reminder) - - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) - is_valid = True - if not user or not channel: - is_valid = False - log.info( - f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." - ) - scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) - - return is_valid, user, channel - - @staticmethod - async def _send_confirmation( - ctx: Context, - on_success: str, - reminder_id: t.Union[str, int] - ) -> None: - """Send an embed confirming the reminder change was made successfully.""" - embed = discord.Embed( - description=on_success, - colour=discord.Colour.green(), - title=random.choice(POSITIVE_REPLIES) - ) - - footer_str = f"ID: {reminder_id}" - - embed.set_footer(text=footer_str) - - await ctx.send(embed=embed) - - @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: - """ - Returns whether or not the list of mentions is allowed. - - Conditions: - - Role reminders are Mods+ - - Reminders for other users are Helpers+ - - If mentions aren't allowed, also return the type of mention(s) disallowed. - """ - if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): - return False, "members/roles" - elif await has_no_roles_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, (discord.User, discord.Member)) for mention in mentions), "roles" - else: - return True, "" - - @staticmethod - async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: - """ - Filter mentions to see if the user can mention, and sends a denial if not allowed. - - Returns whether or not the validation is successful. - """ - mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) - - if not mentions or mentions_allowed: - return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False - - async def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: - """Converts Role and Member ids to their corresponding objects if possible.""" - guild = self.bot.get_guild(Guild.id) - for mention_id in mention_ids: - member = await get_or_fetch_member(guild, mention_id) - if mentionable := (member or guild.get_role(mention_id)): - yield mentionable - - def schedule_reminder(self, reminder: dict) -> None: - """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_datetime = isoparse(reminder['expiration']) - self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) - - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: - """ - Edits a reminder in the database given the ID and payload. - - Returns the edited reminder. - """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), - json=payload - ) - return reminder - - async def _reschedule_reminder(self, reminder: dict) -> None: - """Reschedule a reminder object.""" - log.trace(f"Cancelling old task #{reminder['id']}") - self.scheduler.cancel(reminder["id"]) - - log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_reminder(reminder) - - @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, expected_time: t.Optional[time.Timestamp] = None) -> None: - """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) - if not is_valid: - # No need to cancel the task too; it'll simply be done once this coroutine returns. - return - embed = discord.Embed() - if expected_time: - embed.colour = discord.Colour.red() - embed.set_author( - icon_url=Icons.remind_red, - name="Sorry, your reminder should have arrived earlier!" - ) - else: - embed.colour = discord.Colour.og_blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}" - - # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id - additional_mentions = ' '.join([ - mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"]) - ]) - - jump_url = reminder.get("jump_url") - embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" - partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) - try: - await partial_message.reply(content=f"{additional_mentions}", embed=embed) - except discord.HTTPException as e: - log.info( - f"There was an error when trying to reply to a reminder invocation message, {e}, " - "fall back to using jump_url" - ) - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) - - log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") - await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") - - @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None - ) -> None: - """ - Commands for managing your reminders. - - The `expiration` duration of `!remind new` supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. - """ - await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) - - @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None - ) -> None: - """ - Set yourself a simple reminder. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. - """ - # If the user is not staff, partner or part of the python community, - # we need to verify whether or not to make a reminder at all. - if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): - - # If they don't have permission to set a reminder in this channel - if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") - return - - # Get their current active reminders - active_reminders = await self.bot.api_client.get( - 'bot/reminders', - params={ - 'author__id': str(ctx.author.id) - } - ) - - # 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: - await send_denial(ctx, "You have too many active reminders!") - return - - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - - # If `content` isn't provided then we try to get message content of a replied message - if not content: - if reference := ctx.message.reference: - if isinstance((resolved_message := reference.resolved), discord.Message): - content = resolved_message.content - # If we weren't able to get the content of a replied message - if content is None: - await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") - return - - # If the replied message has no content (e.g. only attachments/embeds) - if content == "": - content = "See referenced message." - - # Now we can attempt to actually set the reminder. - reminder = await self.bot.api_client.post( - 'bot/reminders', - json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, - } - ) - - formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) - mention_string = f"Your reminder will arrive on {formatted_time}" - - if mentions: - mention_string += f" and will mention {len(mentions)} other(s)" - mention_string += "!" - - # Confirm to the user that it worked. - await self._send_confirmation( - ctx, - on_success=mention_string, - reminder_id=reminder["id"] - ) - - self.schedule_reminder(reminder) - - @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: - """View a paginated embed of all reminders for your user.""" - # Get all the user's reminders from the database. - data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} - ) - - # Make a list of tuples so it can be sorted by time. - reminders = sorted( - ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) - for rem in data - ), - key=itemgetter(1) - ) - - lines = [] - - for content, remind_at, id_, mentions in reminders: - # Parse and humanize the time, make it pretty :D - expiry = time.format_relative(remind_at) - - mentions = ", ".join([ - # Both Role and User objects have the `name` attribute - mention.name async for mention in self.get_mentionables(mentions) - ]) - mention_string = f"\n**Mentions:** {mentions}" if mentions else "" - - text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string} - {content} - """).strip() - - lines.append(text) - - embed = discord.Embed() - embed.colour = discord.Colour.og_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." - await ctx.send(embed=embed) - return - - # Construct the embed and paginate it. - embed.colour = discord.Colour.og_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) -> None: - """ - Commands for modifying your current reminders. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. - """ - await ctx.send_help(ctx.command) - - @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: - """ - Edit one of your reminder's expiration. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. - """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) - - @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" - await self.edit_reminder(ctx, id_, {"content": content}) - - @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None: - """Edit one of your reminder's mentions.""" - # Remove duplicate mentions - mentions = set(mentions) - mentions.discard(ctx.author) - - # Filter mentions to see if the user can mention members/roles - if not await self.validate_mentions(ctx, mentions): - return - - mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - - @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: - """Edits a reminder with the given payload, then sends a confirmation message.""" - if not await self._can_modify(ctx, id_): - return - reminder = await self._edit_reminder(id_, payload) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - ) - await self._reschedule_reminder(reminder) - - @remind_group.command("delete", aliases=("remove", "cancel")) - @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - if not await self._can_modify(ctx, id_): - return - - await self.bot.api_client.delete(f"bot/reminders/{id_}") - self.scheduler.cancel(id_) - - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_ - ) - - async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: - """ - Check whether the reminder can be modified by the ctx author. - - The check passes when the user is an admin, or if they created the reminder. - """ - if await has_any_role_check(ctx, Roles.admins): - return True - - api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") - if not api_response["author"] == ctx.author.id: - log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") - await send_denial(ctx, "You can't modify reminders of other users!") - return False - - log.debug(f"{ctx.author} is the reminder author and passes the check.") - return True - - -def setup(bot: Bot) -> None: - """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) |