Repository: python-discord/bot

Issue:
Role reminders

We currently have a feature `!remind` which will send the caller a reminder after a certain amount of time has passed.

But sometimes, a moderator or admin will want to create a reminder for his whole team. For example, maybe something needs to be followed up in 7 hours but the moderator or admin will not be available at that time.

It should also be possible to create a reminder for another user than yourself.

## New command syntax
`!remind <optional role mention or user mention> <duration>`.

For example, `!remind @Admins 7H23M` or `!remind @joe 1w`

## Access restriction
- Role reminders should be restricted to Mods+
- Reminders for yourself or for other users should be restricted to Helpers+

## Implementation details
Use a greedy converter. We're already doing this for the `!clean` command to take an optional channel, for instance.


Discussion:
> Reminders for yourself or for other users should be restricted to Helpers+

Do you mean that normal users can't create reminders anymore? Only Helpers+ can create reminders?
@lemonsaurus I'll clarify some implementation details, let me know if it matches what you had in mind.

- Roles/persons to ping are sent to the site using the api and stored in a new field.
- Allow setting reminders for multiple roles/persons (or a combination of the two) in one go.

---
Questions:
- Do we want to set a limit on the number of roles/persons to set a single reminder for?
- Do we want to care about what roles we can set reminders for?
@ks129 
> Do you mean that normal users can't create reminders anymore? Only Helpers+ can create reminders?

My mistake, I thought reminders were already restricted to Helpers+. Seeing as they are not, I guess they should just stay the way they are.

@kosayoda 
> Roles/persons to ping are sent to the site using the api and stored in a new field.

Yes, this will be necessary. Right now we're only storing the `author` in the database, and always sending the ping to them.

The only other alternative I can think of would be that we "bake" the ping into the `content`, but this is probably messier.


> Allow setting reminders for multiple roles/persons (or a combination of the two) in one go.

Sounds ideal.


> Do we want to set a limit on the number of roles/persons to set a single reminder for?
> Do we want to care about what roles we can set reminders for?

Since this will only be available to Mods+, naw. We trust them not to do anything stupid.


Resolving patch:
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -9,13 +9,14 @@
 import discord
 from dateutil.parser import isoparse
 from dateutil.relativedelta import relativedelta
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, Greedy, group
 
 from bot.bot import Bot
-from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
+from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES
 from bot.converters import Duration
 from bot.pagination import LinePaginator
 from bot.utils.checks import without_role_check
+from bot.utils.messages import send_denial
 from bot.utils.scheduling import Scheduler
 from bot.utils.time import humanize_delta
 
@@ -24,6 +25,8 @@
 WHITELISTED_CHANNELS = Guild.reminder_whitelist
 MAXIMUM_REMINDERS = 5
 
+Mentionable = t.Union[discord.Member, discord.Role]
+
 
 class Reminders(Cog):
     """Provide in-channel reminder functionality."""
@@ -99,6 +102,46 @@ async def _send_confirmation(
 
         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 without_role_check(ctx, *STAFF_ROLES):
+            return False, "members/roles"
+        elif without_role_check(ctx, *MODERATION_ROLES):
+            return all(isinstance(mention, 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
+
+    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:
+            if (mentionable := (guild.get_member(mention_id) 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_id = reminder["id"]
@@ -120,6 +163,19 @@ async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) ->
             # Now we can remove it from the schedule list
             self.scheduler.cancel(reminder_id)
 
+    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']}")
@@ -153,36 +209,39 @@ async def send_reminder(self, reminder: dict, late: relativedelta = None) -> Non
                 name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
             )
 
+        additional_mentions = ' '.join(
+            mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])
+        )
+
         await channel.send(
-            content=user.mention,
+            content=f"{user.mention} {additional_mentions}",
             embed=embed
         )
         await self._delete_reminder(reminder["id"])
 
     @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
-    async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
+    async def remind_group(
+        self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
+    ) -> None:
         """Commands for managing your reminders."""
-        await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
+        await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content)
 
     @remind_group.command(name="new", aliases=("add", "create"))
-    async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]:
+    async def new_reminder(
+        self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
+    ) -> None:
         """
         Set yourself a simple reminder.
 
         Expiration is parsed per: http://strftime.org/
         """
-        embed = discord.Embed()
-
         # If the user is not staff, we need to verify whether or not to make a reminder at all.
         if without_role_check(ctx, *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 = discord.Colour.red()
-                embed.title = random.choice(NEGATIVE_REPLIES)
-                embed.description = "Sorry, you can't do that here!"
-
-                return await ctx.send(embed=embed)
+                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(
@@ -195,11 +254,18 @@ async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str
             # 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 = discord.Colour.red()
-                embed.title = random.choice(NEGATIVE_REPLIES)
-                embed.description = "You have too many active reminders!"
+                await send_denial(ctx, "You have too many active reminders!")
+                return
 
-                return await ctx.send(embed=embed)
+        # 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]
 
         # Now we can attempt to actually set the reminder.
         reminder = await self.bot.api_client.post(
@@ -209,17 +275,22 @@ async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str
                 'channel_id': ctx.message.channel.id,
                 'jump_url': ctx.message.jump_url,
                 'content': content,
-                'expiration': expiration.isoformat()
+                'expiration': expiration.isoformat(),
+                'mentions': mention_ids,
             }
         )
 
         now = datetime.utcnow() - timedelta(seconds=1)
         humanized_delta = humanize_delta(relativedelta(expiration, now))
+        mention_string = (
+            f"Your reminder will arrive in {humanized_delta} "
+            f"and will mention {len(mentions)} other(s)!"
+        )
 
         # Confirm to the user that it worked.
         await self._send_confirmation(
             ctx,
-            on_success=f"Your reminder will arrive in {humanized_delta}!",
+            on_success=mention_string,
             reminder_id=reminder["id"],
             delivery_dt=expiration,
         )
@@ -227,7 +298,7 @@ async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str
         self.schedule_reminder(reminder)
 
     @remind_group.command(name="list")
-    async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
+    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(
@@ -240,7 +311,7 @@ async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
         # Make a list of tuples so it can be sorted by time.
         reminders = sorted(
             (
-                (rem['content'], rem['expiration'], rem['id'])
+                (rem['content'], rem['expiration'], rem['id'], rem['mentions'])
                 for rem in data
             ),
             key=itemgetter(1)
@@ -248,13 +319,19 @@ async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
 
         lines = []
 
-        for content, remind_at, id_ in reminders:
+        for content, remind_at, id_, mentions in reminders:
             # Parse and humanize the time, make it pretty :D
             remind_datetime = isoparse(remind_at).replace(tzinfo=None)
             time = humanize_delta(relativedelta(remind_datetime, now))
 
+            mentions = ", ".join(
+                # Both Role and User objects have the `name` attribute
+                mention.name for mention in self.get_mentionables(mentions)
+            )
+            mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
+
             text = textwrap.dedent(f"""
-            **Reminder #{id_}:** *expires in {time}* (ID: {id_})
+            **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string}
             {content}
             """).strip()
 
@@ -267,7 +344,8 @@ async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
         # 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)
+            await ctx.send(embed=embed)
+            return
 
         # Construct the embed and paginate it.
         embed.colour = discord.Colour.blurple()
@@ -287,37 +365,37 @@ async def edit_reminder_group(self, ctx: Context) -> None:
     @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.
+        Edit one of your reminder's expiration.
 
         Expiration is parsed per: http://strftime.org/
         """
-        # Send the request to update the reminder in the database
-        reminder = await self.bot.api_client.patch(
-            'bot/reminders/' + str(id_),
-            json={'expiration': expiration.isoformat()}
-        )
-
-        # Send a confirmation message to the channel
-        await self._send_confirmation(
-            ctx,
-            on_success="That reminder has been edited successfully!",
-            reminder_id=id_,
-            delivery_dt=expiration,
-        )
-
-        await self._reschedule_reminder(reminder)
+        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."""
-        # Send the request to update the reminder in the database
-        reminder = await self.bot.api_client.patch(
-            'bot/reminders/' + str(id_),
-            json={'content': 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[Mentionable]) -> 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})
+
+    async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:
+        """Edits a reminder with the given payload, then sends a confirmation message."""
+        reminder = await self._edit_reminder(id_, payload)
 
-        # Parse the reminder expiration back into a datetime for the confirmation message
-        expiration = isoparse(reminder['expiration']).replace(tzinfo=None)
+        # Parse the reminder expiration back into a datetime
+        expiration = isoparse(reminder["expiration"]).replace(tzinfo=None)
 
         # Send a confirmation message to the channel
         await self._send_confirmation(
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,15 +1,17 @@
 import asyncio
 import contextlib
 import logging
+import random
 import re
 from io import BytesIO
 from typing import List, Optional, Sequence, Union
 
-from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook
+from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook
 from discord.abc import Snowflake
 from discord.errors import HTTPException
+from discord.ext.commands import Context
 
-from bot.constants import Emojis
+from bot.constants import Emojis, NEGATIVE_REPLIES
 
 log = logging.getLogger(__name__)
 
@@ -132,3 +134,13 @@ def replace_e(match: re.Match) -> str:
         return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)
     else:
         return username  # Empty string or None
+
+
+async def send_denial(ctx: Context, reason: str) -> None:
+    """Send an embed denying the user with the given reason."""
+    embed = Embed()
+    embed.colour = Colour.red()
+    embed.title = random.choice(NEGATIVE_REPLIES)
+    embed.description = reason
+
+    await ctx.send(embed=embed)


Test patch: