diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py index 8d45c2466a..13cd7e2184 100644 --- a/bot/exts/help_channels/_caches.py +++ b/bot/exts/help_channels/_caches.py @@ -29,7 +29,9 @@ # RedisCache[discord.User.id, bool] help_dm = RedisCache(namespace="HelpChannels.help_dm") -# This cache tracks member who are participating and opted in to help channel dms. +# This cache keeps track of some attributes of the messages sent to members opted +# in for help channel DMs when they participate in help channels. +# It maps help channel IDs to sets of strings of the pattern . # serialise the set as a comma separated string to allow usage with redis -# RedisCache[discord.TextChannel.id, str[set[discord.User.id]]] -session_participants = RedisCache(namespace="HelpChannels.session_participants") +# RedisCache[discord.TextChannel.id, str[set[DMMessageInfo]]] +helpdm_messages = RedisCache(namespace="HelpChannels.helpdm_messages") diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 60209ba6e0..99d38f4ea6 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -14,7 +14,7 @@ from bot.constants import Channels, RedirectOutput from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.log import get_logger -from bot.utils import channel as channel_utils, lock, members, scheduling +from bot.utils import channel as channel_utils, lock, members, messages, scheduling, users log = get_logger(__name__) @@ -25,6 +25,29 @@ AVAILABLE_HELP_CHANNELS = "**Currently available help channel(s):** {available}" +class DMMessageInfo(t.NamedTuple): + """ + Stores the recipient's id, the DM channel id and a message id. + + This is used for easy access to the values stored in the + helpdm_messages cache. + """ + + recipient_id: int + dm_channel_id: int + message_id: int + + def get_serialised_string(self) -> str: + """Serialise the ids as a string for storing in redis.""" + return f"{self.recipient_id}-{self.dm_channel_id}-{self.message_id}" + + @classmethod + def from_string(cls, info_string: str) -> "DMMessageInfo": + """Deserialise ids from a hyphen-separated string.""" + ids = (int(id_) for id_ in info_string.split("-")) + return cls(*ids) + + class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. @@ -408,6 +431,7 @@ async def unclaim_channel(self, channel: discord.TextChannel, *, closed_on: _cha Unpin the claimant's question message and move the channel to the Dormant category. Remove the cooldown role from the channel claimant if they have no other channels claimed. + Edit the messages sent to members who participated in the channel and had help DMs enabled. Cancel the scheduled cooldown role removal task. `closed_on` is the reason that the channel was closed. See _channel.ClosingReason for possible values. @@ -431,7 +455,8 @@ async def _unclaim_channel( ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) - await _caches.session_participants.delete(channel.id) + await self.edit_helpdm_messages(channel.id) + await _caches.helpdm_messages.delete(channel.id) claimant = await members.get_or_fetch_member(self.guild, claimant_id) if claimant is None: @@ -542,14 +567,20 @@ async def update_available_help_channels(self) -> None: await _caches.dynamic_message.set("message_id", self.dynamic_message) @staticmethod - def _serialise_session_participants(participants: set[int]) -> str: - """Convert a set to a comma separated string.""" - return ','.join(str(p) for p in participants) + async def _set_helpdm_messages(help_channel_id: int, ids: t.Iterable[DMMessageInfo]) -> None: + """Set the value for help dm messages associated with the help channel.""" + info_string = ",".join(info.get_serialised_string() for info in ids) + await _caches.helpdm_messages.set(help_channel_id, info_string) @staticmethod - def _deserialise_session_participants(s: str) -> set[int]: - """Convert a comma separated string into a set.""" - return set(int(user_id) for user_id in s.split(",") if user_id != "") + async def _get_helpdm_messages(help_channel_id: int) -> set[DMMessageInfo]: + """Get parsed help dm messages associated with the help channel.""" + info_string = await _caches.helpdm_messages.get(help_channel_id) or "" + return { + DMMessageInfo.from_string(message_info) + for message_info in info_string.split(",") + if message_info + } @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) @@ -559,7 +590,9 @@ async def notify_session_participants(self, message: discord.Message) -> None: If they meet the requirements they are notified. """ - if await _caches.claimants.get(message.channel.id) == message.author.id: + claimant_id = await _caches.claimants.get(message.channel.id) + claimant = await users.get_or_fetch_user(claimant_id) + if claimant == message.author: return # Ignore messages sent by claimants if not await _caches.help_dm.get(message.author.id): @@ -568,23 +601,28 @@ async def notify_session_participants(self, message: discord.Message) -> None: if (await self.bot.get_context(message)).command == self.close_command: return # Ignore messages that are closing the channel - session_participants = self._deserialise_session_participants( - await _caches.session_participants.get(message.channel.id) or "" - ) + helpdm_messages = await self._get_helpdm_messages(message.channel.id) + session_participants = {message_info.recipient_id for message_info in helpdm_messages} if message.author.id not in session_participants: - session_participants.add(message.author.id) - + # Changes made to the construction of this embed will be + # reflected in the embed that gets sent when the help + # channel is closed. embed = discord.Embed( title="Currently Helping", - description=f"You're currently helping in {message.channel.mention}", + description=f"You're currently helping {claimant.mention} ({claimant}) in {message.channel.mention}", color=constants.Colours.bright_green, timestamp=message.created_at ) + embed.set_thumbnail(url=claimant.display_avatar.url) + + formatted_message = _message.shorten_text(message.content) + if formatted_message: + embed.add_field(name="Their message", value=formatted_message, inline=False) embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") try: - await message.author.send(embed=embed) + dm_message = await message.author.send(embed=embed) except discord.Forbidden: log.trace( f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. " @@ -597,12 +635,22 @@ async def notify_session_participants(self, message: discord.Message) -> None: "To receive updates on help channels you're active in, enable your DMs.", delete_after=RedirectOutput.delete_delay ) - return - - await _caches.session_participants.set( - message.channel.id, - self._serialise_session_participants(session_participants) - ) + else: + info = DMMessageInfo(message.author.id, dm_message.channel.id, dm_message.id) + helpdm_messages.add(info) + await self._set_helpdm_messages(message.channel.id, helpdm_messages) + + async def edit_helpdm_messages(self, help_channel_id: int) -> None: + """Edit help DM messages upon help channel closure.""" + for message_info in await self._get_helpdm_messages(help_channel_id): + dm_message = await messages.get_or_fetch_message(message_info.message_id, message_info.dm_channel_id) + + # This embed was created in notify_session_participants + embed = dm_message.embeds[0].copy() + embed.color = constants.Colours.soft_orange + embed.title = "Previously helped" + embed.description = embed.description.replace("You're currently helping", "You helped") + await dm_message.edit(embed=embed) @commands.command(name="helpdm") async def helpdm_command( diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 241dd606c2..0476572e34 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -92,6 +92,11 @@ async def is_empty(channel: discord.TextChannel) -> bool: return False +def shorten_text(message: str) -> str: + """Shorten the message if needed to fit within 100 characters.""" + return textwrap.shorten(message, width=100, placeholder="...") + + async def dm_on_open(message: discord.Message) -> None: """ DM claimant with a link to the claimed channel's first message, with a 100 letter preview of the message. @@ -106,7 +111,7 @@ async def dm_on_open(message: discord.Message) -> None: ) embed.set_thumbnail(url=constants.Icons.green_questionmark) - formatted_message = textwrap.shorten(message.content, width=100, placeholder="...") + formatted_message = shorten_text(message.content) if formatted_message: embed.add_field(name="Your message", value=formatted_message, inline=False) embed.add_field( diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 954a10e562..f8bef6a855 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -57,7 +57,9 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: return getattr(channel, "category_id", None) == category_id -async def get_or_fetch_channel(channel_id: int) -> discord.abc.GuildChannel: +async def get_or_fetch_channel( + channel_id: int, +) -> Union[discord.abc.GuildChannel, discord.abc.PrivateChannel, discord.Thread]: """Attempt to get or fetch a channel and return it.""" log.trace(f"Getting the channel {channel_id}.") diff --git a/bot/utils/messages.py b/bot/utils/messages.py index e55c07062c..8fcc41d2f8 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -11,7 +11,7 @@ import bot from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES from bot.log import get_logger -from bot.utils import scheduling +from bot.utils import channel as channel_util, scheduling log = get_logger(__name__) @@ -235,6 +235,20 @@ async def send_denial(ctx: Context, reason: str) -> discord.Message: return await ctx.send(embed=embed) +async def get_or_fetch_message(message_id: int, channel_id: int) -> discord.Message: + """Get a message from the cache or fetch it if needed.""" + log.trace(f"Getting message {message_id}.") + + message = discord.utils.get(bot.instance.cached_messages, id=message_id) + if not message: + log.debug(f"Message {message_id} is not in cache; fetching from API.") + channel = await channel_util.get_or_fetch_channel(channel_id) + message = await channel.fetch_message(message_id) + + log.trace(f"Message {channel_id}-{message_id} retrieved.") + return message + + def format_user(user: discord.abc.User) -> str: """Return a string for `user` which has their mention and ID.""" return f"{user.mention} (`{user.id}`)" diff --git a/bot/utils/users.py b/bot/utils/users.py new file mode 100644 index 0000000000..04cc627340 --- /dev/null +++ b/bot/utils/users.py @@ -0,0 +1,19 @@ +import discord + +import bot +from bot.log import get_logger + +log = get_logger(__name__) + + +async def get_or_fetch_user(user_id: int) -> discord.User: + """Get a user from the cache or fetch the user if needed.""" + log.trace(f"Getting the user {user_id}.") + + user = bot.instance.get_user(user_id) + if not user: + log.debug(f"User {user_id} is not in cache; fetching from API.") + user = await bot.instance.fetch_user(user_id) + + log.trace(f"User {user} ({user.id}) retrieved.") + return user