From e9c2bcce3e00e88ccff35885e50c4ed3ecbd9e0f Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 3 Mar 2021 18:03:44 +0530 Subject: Send webhook embed containing information about the message if there is a message link in the incident report --- bot/constants.py | 1 + bot/exts/moderation/incidents.py | 73 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f99913b17..33c911874 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -481,6 +481,7 @@ class Webhooks(metaclass=YAMLGetter): big_brother: int dev_log: int duck_pond: int + incidents: int incidents_archive: int diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a3d90e3fe..0d63ef34f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,16 +1,18 @@ import asyncio import logging +import re import typing as t from datetime import datetime from enum import Enum import discord +from async_rediscache import RedisCache from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling -from bot.utils.messages import sub_clyde +from bot.utils.messages import format_user, sub_clyde log = logging.getLogger(__name__) @@ -22,6 +24,10 @@ CRAWL_LIMIT = 50 # Seconds for `crawl_task` to sleep after adding reactions to a message CRAWL_SLEEP = 2 +DISCORD_MESSAGE_LINK_RE = re.compile( + r"discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" +) + class Signal(Enum): """ @@ -114,9 +120,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -131,6 +137,32 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) +async def make_message_link_embed(incident: discord.Message, message_link: str) -> discord.Embed: + """ + Create an embed representation of discord message link contained in the incident report. + + The Embed would contain the following information --> + Author: @Jason Terror â™Ļ (736234578745884682) + Channel: Special/#bot-commands (814190307980607493) + Content: This is a very important message! + """ + channel_id = int(message_link.split("/")[3]) + msg_id = int(message_link.split("/")[4]) + + channel = incident.guild.get_channel(channel_id) + message = await channel.fetch_message(msg_id) + + text = message.content + channel = message.channel + description = ( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + "\n" + ) + return discord.Embed(description=description) + + async def add_signals(incident: discord.Message) -> None: """ Add `Signal` member emoji to `incident` as reactions. @@ -186,6 +218,10 @@ class Incidents(Cog): Please refer to function docstrings for implementation details. """ + # This dictionary maps a incident message to the message link embeds(s) sent by it + # RedisCache[discord.Message.id, List[discord.Message.id]] + message_link_embeds_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -340,6 +376,12 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") + log.trace("Deleting discord links webhook message.") + webhook_msg_id = await self.message_link_embeds_cache.get(incident.id) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await webhook.delete_message(webhook_msg_id) + log.trace("Successfully deleted discord links webhook message.") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -421,6 +463,29 @@ class Incidents(Cog): async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): + message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + if message_links: + + embeds = [] + for message_link in message_links: + embeds.append( + await make_message_link_embed(message, message_link) + ) + + try: + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + webhook_msg = await webhook.send( + embeds=embeds, + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + except Exception: + log.exception(f"Failed to send message link embeds {message.id} to #incidents") + else: + log.trace("Message Link Embeds Sent successfully!") + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + await add_signals(message) -- cgit v1.2.3 From a2958fb30320b6f956a14a5f4669af527f97a523 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 3 Mar 2021 18:08:54 +0530 Subject: Add incidents webhook to default config template --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index d77eacc7e..70e972086 100644 --- a/config-default.yml +++ b/config-default.yml @@ -308,6 +308,7 @@ guild: big_brother: 569133704568373283 dev_log: 680501655111729222 duck_pond: 637821475327311927 + incidents: 816650601844572212 incidents_archive: 720671599790915702 python_news: &PYNEWS_WEBHOOK 704381182279942324 -- cgit v1.2.3 From c32aee329016a8f9d9947d75a52ac26f8d90029c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 13:47:53 +0530 Subject: Send multiple webhook messages in case of more than 10 message links --- bot/exts/moderation/incidents.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0d63ef34f..10a1f5fbd 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -8,6 +8,7 @@ from enum import Enum import discord from async_rediscache import RedisCache from discord.ext.commands import Cog +from more_itertools.recipes import grouper from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -377,9 +378,14 @@ class Incidents(Cog): log.trace("Deletion was confirmed") log.trace("Deleting discord links webhook message.") - webhook_msg_id = await self.message_link_embeds_cache.get(incident.id) + webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) + webhook_msg_ids = webhook_msg_ids.split(',') webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await webhook.delete_message(webhook_msg_id) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + log.trace("Successfully deleted discord links webhook message.") async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: @@ -474,17 +480,24 @@ class Incidents(Cog): try: webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_msg = await webhook.send( - embeds=embeds, - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True - ) + webhook_embed_list = list(grouper(embeds, 10)) + webhook_msg_ids = [] + + for x, embed in enumerate(webhook_embed_list): + webhook_msg = await webhook.send( + embeds=[x for x in embed if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + webhook_msg_ids.append(webhook_msg.id) + log.trace(f"Message Link Embed {x+1}/{len(webhook_embed_list)} Sent Succesfully") + except Exception: log.exception(f"Failed to send message link embeds {message.id} to #incidents") else: + await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) log.trace("Message Link Embeds Sent successfully!") - await self.message_link_embeds_cache.set(message.id, webhook_msg.id) await add_signals(message) -- cgit v1.2.3 From 7c4fdbaf49202c1b0b7340f3c53c88da2fb88330 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 16:44:21 +0530 Subject: Use MessageConverter to find messages --- bot/exts/moderation/incidents.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 10a1f5fbd..198224b83 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -7,10 +7,10 @@ from enum import Enum import discord from async_rediscache import RedisCache -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context, MessageConverter from more_itertools.recipes import grouper -from bot.bot import Bot +from bot import bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -26,7 +26,8 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r"discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" + r"http(?:s):\/\/discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" + r"(?:\/|slash)[0-9]{18}" ) @@ -138,7 +139,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def make_message_link_embed(incident: discord.Message, message_link: str) -> discord.Embed: +async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: """ Create an embed representation of discord message link contained in the incident report. @@ -147,21 +148,28 @@ async def make_message_link_embed(incident: discord.Message, message_link: str) Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ - channel_id = int(message_link.split("/")[3]) - msg_id = int(message_link.split("/")[4]) - - channel = incident.guild.get_channel(channel_id) - message = await channel.fetch_message(msg_id) - - text = message.content - channel = message.channel - description = ( - f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" - "\n" - ) - return discord.Embed(description=description) + embed = discord.Embed() + + try: + message_convert_object = MessageConverter() + message = await message_convert_object.convert(ctx, message_link) + + except Exception as e: + embed.title = f"{e}" + embed.colour = Colours.soft_red + + else: + text = message.content + channel = message.channel + + embed.description = ( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + "\n" + ) + + return embed async def add_signals(incident: discord.Message) -> None: @@ -223,7 +231,7 @@ class Incidents(Cog): # RedisCache[discord.Message.id, List[discord.Message.id]] message_link_embeds_cache = RedisCache() - def __init__(self, bot: Bot) -> None: + def __init__(self, bot: bot.Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -470,13 +478,13 @@ class Incidents(Cog): """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + print(message_links) if message_links: embeds = [] for message_link in message_links: - embeds.append( - await make_message_link_embed(message, message_link) - ) + ctx = await self.bot.get_context(message) + embeds.append(await make_message_link_embed(ctx, message_link)) try: webhook = await self.bot.fetch_webhook(Webhooks.incidents) @@ -491,17 +499,19 @@ class Incidents(Cog): wait=True ) webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x+1}/{len(webhook_embed_list)} Sent Succesfully") + log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") except Exception: log.exception(f"Failed to send message link embeds {message.id} to #incidents") + else: await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) log.trace("Message Link Embeds Sent successfully!") + log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") await add_signals(message) -def setup(bot: Bot) -> None: +def setup(bot: bot.Bot) -> None: """Load the Incidents cog.""" bot.add_cog(Incidents(bot)) -- cgit v1.2.3 From d52127300289d1e054c931cc3493e239f914cf27 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 16:46:47 +0530 Subject: Use str() when checking for message.content --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 198224b83..9ee1407d4 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -477,7 +477,7 @@ class Incidents(Cog): async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): - message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) print(message_links) if message_links: -- cgit v1.2.3 From a6c609fcc745b9cb99ec1fcfc365b1f364e6ff31 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 07:56:19 +0530 Subject: Fix tests according to the changes done to incidents.py --- bot/exts/moderation/incidents.py | 1 - tests/bot/exts/moderation/test_incidents.py | 21 +++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9ee1407d4..813b717a8 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -478,7 +478,6 @@ class Incidents(Cog): """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - print(message_links) if message_links: embeds = [] diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf..3c991dacc 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import aiohttp import discord +from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents @@ -22,6 +23,22 @@ from tests.helpers import ( MockUser, ) +redis_session = None +redis_loop = asyncio.get_event_loop() + + +def setUpModule(): # noqa: N802 + """Create and connect to the fakeredis session.""" + global redis_session + redis_session = RedisSession(use_fakeredis=True) + redis_loop.run_until_complete(redis_session.connect()) + + +def tearDownModule(): # noqa: N802 + """Close the fakeredis session.""" + if redis_session: + redis_loop.run_until_complete(redis_session.close()) + class MockAsyncIterable: """ @@ -513,7 +530,7 @@ class TestProcessEvent(TestIncidents): with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), + incident=MockMessage(id=123), member=MockMember(roles=[MockRole(id=1)]) ) @@ -533,7 +550,7 @@ class TestProcessEvent(TestIncidents): with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), + incident=MockMessage(id=123), member=MockMember(roles=[MockRole(id=1)]) ) except asyncio.TimeoutError: -- cgit v1.2.3 From e113b17f68452573b1b236f7577120dd3783f6da Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 11:28:46 +0530 Subject: Rollback to changes which aren't required --- bot/exts/moderation/incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 813b717a8..201c6d1ca 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -10,7 +10,7 @@ from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter from more_itertools.recipes import grouper -from bot import bot +from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -122,9 +122,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -231,7 +231,7 @@ class Incidents(Cog): # RedisCache[discord.Message.id, List[discord.Message.id]] message_link_embeds_cache = RedisCache() - def __init__(self, bot: bot.Bot) -> None: + def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -511,6 +511,6 @@ class Incidents(Cog): await add_signals(message) -def setup(bot: bot.Bot) -> None: +def setup(bot: Bot) -> None: """Load the Incidents cog.""" bot.add_cog(Incidents(bot)) -- cgit v1.2.3 From 93f25e91808b9ed83e0201e26c8abf8841caf10c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 11:48:26 +0530 Subject: If message content more than 500 characters shorten it done to 300 characters --- bot/exts/moderation/incidents.py | 58 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 201c6d1ca..1f1f20d6c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -122,9 +122,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -165,7 +165,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em embed.description = ( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + f"**Content:** {text[:300] + '...' if len(text) > 500 else text}\n" "\n" ) @@ -485,31 +485,41 @@ class Incidents(Cog): ctx = await self.bot.get_context(message) embeds.append(await make_message_link_embed(ctx, message_link)) - try: - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_embed_list = list(grouper(embeds, 10)) - webhook_msg_ids = [] + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + webhook_embed_list = list(grouper(embeds, 10)) - for x, embed in enumerate(webhook_embed_list): - webhook_msg = await webhook.send( - embeds=[x for x in embed if x is not None], - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True - ) - webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") - - except Exception: - log.exception(f"Failed to send message link embeds {message.id} to #incidents") - - else: - await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) - log.trace("Message Link Embeds Sent successfully!") + await self.send_webhooks(webhook_embed_list, message, webhook) log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") await add_signals(message) + async def send_webhooks( + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook + ) -> t.List[int]: + webhook_msg_ids = [] + try: + for x, embed in enumerate(webhook_embed_list): + webhook_msg = await webhook.send( + embeds=[x for x in embed if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + webhook_msg_ids.append(webhook_msg.id) + log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") + + except Exception: + log.exception(f"Failed to send message link embeds {message.id} to #incidents") + + else: + await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) + log.trace("Message Link Embeds Sent successfully!") + + return webhook_msg_ids + def setup(bot: Bot) -> None: """Load the Incidents cog.""" -- cgit v1.2.3 From aaf62d36ba2bcd2593756f19534547f740b57f16 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 12:32:26 +0530 Subject: Add a docstring to 'send_webhooks' function --- bot/exts/moderation/incidents.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 1f1f20d6c..8304df174 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -499,6 +499,15 @@ class Incidents(Cog): message: discord.Message, webhook: discord.Webhook ) -> t.List[int]: + """ + Send Message Link Embeds to #incidents channel. + + Uses the `webhook` passed in as parameter to send the embeds + in `webhook_embed_list` parameter. + + After sending each webhook it maps the `message.id` to the + `webhook_msg_ids` IDs in the async rediscache. + """ webhook_msg_ids = [] try: for x, embed in enumerate(webhook_embed_list): -- cgit v1.2.3 From 39116e698f48468bec19d03e946c271e0083ccf4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 16:48:23 +0530 Subject: Update regex to support all message links i.e. support for 'app', 'canary', 'ptb' --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 8304df174..dabdaed2c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,8 +26,9 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r"http(?:s):\/\/discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" - r"(?:\/|slash)[0-9]{18}" + r'(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/' + r'[0-9]{15,21}' + r'\/[0-9]{15,21}\/[0-9]{15,21})' ) @@ -483,7 +484,7 @@ class Incidents(Cog): embeds = [] for message_link in message_links: ctx = await self.bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link)) + embeds.append(await make_message_link_embed(ctx, message_link[0])) webhook = await self.bot.fetch_webhook(Webhooks.incidents) webhook_embed_list = list(grouper(embeds, 10)) -- cgit v1.2.3 From 10a5909e39b3dcda901a5b50b4ef9327cbd62226 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 16:54:33 +0530 Subject: Run webhook message deletion if webhook_msg_id var is True --- bot/exts/moderation/incidents.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index dabdaed2c..7f8a34a01 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -388,12 +388,14 @@ class Incidents(Cog): log.trace("Deleting discord links webhook message.") webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) - webhook_msg_ids = webhook_msg_ids.split(',') - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + if webhook_msg_ids: + webhook_msg_ids = webhook_msg_ids.split(',') + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 2f8c63f88d1d5345be8b64eeda8fbc098c057a74 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:10:27 +0530 Subject: Modify tests to support redis cache, done with the help @SebastiaanZ --- tests/bot/exts/moderation/test_incidents.py | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3c991dacc..239f86e6f 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -23,22 +23,6 @@ from tests.helpers import ( MockUser, ) -redis_session = None -redis_loop = asyncio.get_event_loop() - - -def setUpModule(): # noqa: N802 - """Create and connect to the fakeredis session.""" - global redis_session - redis_session = RedisSession(use_fakeredis=True) - redis_loop.run_until_complete(redis_session.connect()) - - -def tearDownModule(): # noqa: N802 - """Close the fakeredis session.""" - if redis_session: - redis_loop.run_until_complete(redis_session.close()) - class MockAsyncIterable: """ @@ -300,6 +284,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): the instance as they wish. """ + session = None + + async def flush(self): + """Flush everything from the database to prevent carry-overs between tests.""" + with await self.session.pool as connection: + await connection.flushall() + + async def asyncSetUp(self): + self.session = RedisSession(use_fakeredis=True) + await self.session.connect() + await self.flush() + + async def asyncTearDown(self): + if self.session: + await self.session.close() + def setUp(self): """ Prepare a fresh `Incidents` instance for each test. -- cgit v1.2.3 From bb516c51bb811c51b0781d1898ac8e3d578fd4f7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:17:29 +0530 Subject: Allign comments to maintain readability --- bot/exts/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7f8a34a01..be6708b83 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) -- cgit v1.2.3 From 6f3210dde67b1bcfa1c7c9c96c86f76d36af69f1 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:28:32 +0530 Subject: Ignore N802 in 'asyncSetUp' and 'asyncTearDown' function in test_incidents.py --- tests/bot/exts/moderation/test_incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 239f86e6f..c015951b3 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -291,12 +291,12 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): with await self.session.pool as connection: await connection.flushall() - async def asyncSetUp(self): + async def asyncSetUp(self): # noqa: N802 self.session = RedisSession(use_fakeredis=True) await self.session.connect() await self.flush() - async def asyncTearDown(self): + async def asyncTearDown(self): # noqa: N802 if self.session: await self.session.close() -- cgit v1.2.3 From 9ad28b96db7fd0ebc3b0ee8b1d853de494077944 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 13 Mar 2021 06:37:13 +0530 Subject: Run black code formatter. --- bot/exts/moderation/incidents.py | 74 ++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index be6708b83..6a2c8c4b0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,9 +26,9 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r'(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/' - r'[0-9]{15,21}' - r'\/[0-9]{15,21}\/[0-9]{15,21})' + r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/" + r"[0-9]{15,21}" + r"\/[0-9]{15,21}\/[0-9]{15,21})" ) @@ -72,7 +72,11 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: +async def make_embed( + incident: discord.Message, + outcome: Signal, + actioned_by: discord.Member +) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -110,9 +114,13 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di file = await download_file(attachment) if file is not None: - embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + embed.set_image( + url=f"attachment://{attachment.filename}" + ) # Embed displays the attached file else: - embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file + embed.set_author( + name="[Failed to relay attachment]", url=attachment.proxy_url + ) # Embed links the file else: file = None @@ -182,7 +190,9 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call + if ( + signal_emoji.value in existing_reacts + ): # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") @@ -270,7 +280,12 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: + async def archive( + self, + incident: discord.Message, + outcome: Signal, + actioned_by: discord.Member + ) -> bool: """ Relay an embed representation of `incident` to the #incidents-archive channel. @@ -291,7 +306,9 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + log.info( + f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})" + ) embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: @@ -316,7 +333,9 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + log.trace( + f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted" + ) def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -324,7 +343,12 @@ class Incidents(Cog): coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return scheduling.create_task(coroutine, event_loop=self.bot.loop) - async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: + async def process_event( + self, + reaction: str, + incident: discord.Message, + member: discord.Member + ) -> None: """ Process a `reaction_add` event in #incidents. @@ -366,7 +390,9 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: - log.trace("Original message will not be deleted as we failed to relay it to the archive") + log.trace( + "Original message will not be deleted as we failed to relay it to the archive" + ) return timeout = 5 # Seconds @@ -390,7 +416,7 @@ class Incidents(Cog): webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(',') + webhook_msg_ids = webhook_msg_ids.split(",") webhook = await self.bot.fetch_webhook(Webhooks.incidents) for x, msg in enumerate(webhook_msg_ids): @@ -458,7 +484,9 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + log.trace( + f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}" + ) await self.crawl_task log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") @@ -493,14 +521,16 @@ class Incidents(Cog): await self.send_webhooks(webhook_embed_list, message, webhook) - log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) await add_signals(message) async def send_webhooks( - self, - webhook_embed_list: t.List, - message: discord.Message, - webhook: discord.Webhook + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook ) -> t.List[int]: """ Send Message Link Embeds to #incidents channel. @@ -518,7 +548,7 @@ class Incidents(Cog): embeds=[x for x in embed if x is not None], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, - wait=True + wait=True, ) webhook_msg_ids.append(webhook_msg.id) log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") @@ -527,7 +557,9 @@ class Incidents(Cog): log.exception(f"Failed to send message link embeds {message.id} to #incidents") else: - await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) + await self.message_link_embeds_cache.set( + message.id, ",".join(map(str, webhook_msg_ids)) + ) log.trace("Message Link Embeds Sent successfully!") return webhook_msg_ids -- cgit v1.2.3 From fa54337286adcbb812a0e4a6c53fa730818f1f6c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 20 Mar 2021 15:25:00 +0530 Subject: Apply grammar and style changes. --- bot/exts/moderation/incidents.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 6a2c8c4b0..b77fdfabe 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,6 +1,7 @@ import asyncio import logging import re +import textwrap import typing as t from datetime import datetime from enum import Enum @@ -150,7 +151,7 @@ def has_signals(message: discord.Message) -> bool: async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: """ - Create an embed representation of discord message link contained in the incident report. + Create an embedded representation of the discord message link contained in the incident report. The Embed would contain the following information --> Author: @Jason Terror â™Ļ (736234578745884682) @@ -174,7 +175,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em embed.description = ( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:300] + '...' if len(text) > 500 else text}\n" + f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" "\n" ) @@ -320,7 +321,9 @@ class Incidents(Cog): file=attachment_file, ) except Exception: - log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") + log.exception( + f"Failed to archive incident {incident.id} to #incidents-archive" + ) return False else: log.trace("Message archived successfully!") @@ -498,19 +501,19 @@ class Incidents(Cog): return if not is_incident(message): - log.debug("Ignoring event for a non-incident message") + log.debug("Ignoring event for a non-incident message.") return await self.process_event(str(payload.emoji), message, payload.member) - log.trace("Releasing event lock") + log.trace("Releasing event lock.") @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - if message_links: + if message_links: embeds = [] for message_link in message_links: ctx = await self.bot.get_context(message) @@ -530,16 +533,16 @@ class Incidents(Cog): self, webhook_embed_list: t.List, message: discord.Message, - webhook: discord.Webhook + webhook: discord.Webhook, ) -> t.List[int]: """ Send Message Link Embeds to #incidents channel. - Uses the `webhook` passed in as parameter to send the embeds - in `webhook_embed_list` parameter. + Uses the `webhook` passed in as a parameter to send + the embeds in the `webhook_embed_list` parameter. - After sending each webhook it maps the `message.id` to the - `webhook_msg_ids` IDs in the async rediscache. + After sending each webhook it maps the `message.id` + to the `webhook_msg_ids` IDs in the async redis-cache. """ webhook_msg_ids = [] try: @@ -551,10 +554,14 @@ class Incidents(Cog): wait=True, ) webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") + log.trace( + f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." + ) except Exception: - log.exception(f"Failed to send message link embeds {message.id} to #incidents") + log.exception( + f"Failed to send message link embeds {message.id} to #incidents." + ) else: await self.message_link_embeds_cache.set( -- cgit v1.2.3 From e8625daa99d9bbbd929d132be44164ce1254b74e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 16 Apr 2021 16:49:32 +0530 Subject: Use `DiscordException` instead of broad exception clause. --- bot/exts/moderation/incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b77fdfabe..edf621e02 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -164,7 +164,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message_convert_object = MessageConverter() message = await message_convert_object.convert(ctx, message_link) - except Exception as e: + except discord.DiscordException as e: embed.title = f"{e}" embed.colour = Colours.soft_red @@ -558,7 +558,7 @@ class Incidents(Cog): f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." ) - except Exception: + except discord.DiscordException: log.exception( f"Failed to send message link embeds {message.id} to #incidents." ) -- cgit v1.2.3 From a1bb6f38738b50183ea9042c29d1fbc8b0b18bb7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 08:45:16 +0530 Subject: Refactors code. Earlier on message edit the message wasn't run through extract message links to see if new message links are added or if some got deleted. Similarly the cache was updated when a message got deleted. Now it makes extract message links a helper function and runs it on message edits and deletes in case there are some changes in the message links. This commit also updates the doc strings for functions according to the new changes done. --- bot/exts/moderation/incidents.py | 120 ++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 26 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index edf621e02..77017659e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -207,6 +207,35 @@ async def add_signals(incident: discord.Message) -> None: return +async def extract_message_links(message: discord.Message) -> t.Optional[list]: + """ + Checks if there's any message links in the text content. + + Then passes the the message_link into `make_message_link_embed` to format a + embed for it containing information about the link. + + As discord only allows a max of 10 embeds in a single webhook we need to + group the embeds into group of 10 and then return the list. + + If no links are found for the message, it logs a trace statement. + """ + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + + if message_links: + embeds = [] + for message_link in message_links: + ctx = await message.bot.get_context(message) + embeds.append(await make_message_link_embed(ctx, message_link[0])) + + webhook_embed_list = list(grouper(embeds, 10)) + + return webhook_embed_list + + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) + + class Incidents(Cog): """ Automation for the #incidents channel. @@ -365,6 +394,9 @@ class Incidents(Cog): This ensures that if there is a racing event awaiting the lock, it will fail to find the message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock forever should something go wrong. + + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + webhook message for that particular link from the channel. """ members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element @@ -415,18 +447,8 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") - log.trace("Deleting discord links webhook message.") - webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) - - if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(",") - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") - - log.trace("Successfully deleted discord links webhook message.") + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embeds(incident) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -509,25 +531,53 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" + """ + If the message (`message`) is a incident then run it through `extract_message_links` + to get all the message link embeds (embeds which contain information about that particular + link), this message link embeds are then sent into the channel. + + Also passes the message into `add_signals` if the message is a incident. + """ if is_incident(message): - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + webhook_embed_list = await extract_message_links(message) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await self.send_webhooks(webhook_embed_list, message, webhook) - if message_links: - embeds = [] - for message_link in message_links: - ctx = await self.bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link[0])) + await add_signals(message) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_embed_list = list(grouper(embeds, 10)) + @Cog.listener() + async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: + """ + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + webhook message for that particular link from the channel. - await self.send_webhooks(webhook_embed_list, message, webhook) + If the message edit (`msg_after`) is a incident then run it through `extract_message_links` + to get all the message link embeds (embeds which contain information about that particular + link), this message link embeds are then sent into the channel. - log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." - ) - await add_signals(message) + The edited message is also passed into `add_signals` if it is a incident message. + """ + if is_incident(msg_before): + if msg_before.id in self.message_link_embeds_cache.items: + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embeds(msg_before) + + if is_incident(msg_after): + webhook_embed_list = await extract_message_links(msg_after) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await self.send_webhooks(webhook_embed_list, msg_after, webhook) + + await add_signals(msg_after) + + @Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """ + Deletes the message link embeds found in cache from the channel and cache if the message + is a incident and is found in msg link embeds cache. + """ + if is_incident(message): + if message.id in self.message_link_embeds_cache.items: + await self.delete_msg_link_embeds(message) async def send_webhooks( self, @@ -571,6 +621,24 @@ class Incidents(Cog): return webhook_msg_ids + async def delete_msg_link_embeds(self, message: discord.Message) -> None: + """Delete discord message links message found in cache for `message`.""" + log.trace("Deleting discord links webhook message.") + + webhook_msg_ids = await self.message_link_embeds_cache.get(message.id) + + if webhook_msg_ids: + webhook_msg_ids = webhook_msg_ids.split(",") + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + + await self.message_link_embeds_cache.delete(message.id) + + log.trace("Successfully deleted discord links webhook message.") + def setup(bot: Bot) -> None: """Load the Incidents cog.""" -- cgit v1.2.3 From 5406c45a08ba0532b10cc6609f1f54a9f0e80e3d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 08:48:33 +0530 Subject: Updates type hints for `message_link_embeds_cache`. --- bot/exts/moderation/incidents.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 77017659e..a5e2ef945 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -269,7 +269,13 @@ class Incidents(Cog): """ # This dictionary maps a incident message to the message link embeds(s) sent by it - # RedisCache[discord.Message.id, List[discord.Message.id]] + # + # Discord doesn't allow more than 10 embeds to be sent in a single webhook message + # hence the embeds need to be broken into groups of 10. Since we have multiple embeds + # and RedisCache doesn't allow storing lists, we need to join the list with commas to + # make it a string and then store it. + # + # RedisCache[discord.Message.id, str] message_link_embeds_cache = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 3ca726c0edd838647b99f5a16fe3f15956d59e64 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 17:34:31 +0530 Subject: Revert changes done by black. --- bot/exts/moderation/incidents.py | 56 ++++++++++------------------------------ 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a5e2ef945..c988c45bb 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -73,11 +73,7 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -async def make_embed( - incident: discord.Message, - outcome: Signal, - actioned_by: discord.Member -) -> FileEmbed: +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -115,13 +111,9 @@ async def make_embed( file = await download_file(attachment) if file is not None: - embed.set_image( - url=f"attachment://{attachment.filename}" - ) # Embed displays the attached file + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file else: - embed.set_author( - name="[Failed to relay attachment]", url=attachment.proxy_url - ) # Embed links the file + embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file else: file = None @@ -191,9 +183,7 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - if ( - signal_emoji.value in existing_reacts - ): # This would not raise, but it is a superfluous API call + if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") @@ -316,12 +306,7 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive( - self, - incident: discord.Message, - outcome: Signal, - actioned_by: discord.Member - ) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay an embed representation of `incident` to the #incidents-archive channel. @@ -342,9 +327,7 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.info( - f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})" - ) + log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: @@ -356,9 +339,7 @@ class Incidents(Cog): file=attachment_file, ) except Exception: - log.exception( - f"Failed to archive incident {incident.id} to #incidents-archive" - ) + log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False else: log.trace("Message archived successfully!") @@ -371,9 +352,7 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.trace( - f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted" - ) + log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -381,12 +360,7 @@ class Incidents(Cog): coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return scheduling.create_task(coroutine, event_loop=self.bot.loop) - async def process_event( - self, - reaction: str, - incident: discord.Message, - member: discord.Member - ) -> None: + async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ Process a `reaction_add` event in #incidents. @@ -431,9 +405,7 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: - log.trace( - "Original message will not be deleted as we failed to relay it to the archive" - ) + log.trace("Original message will not be deleted as we failed to relay it to the archive") return timeout = 5 # Seconds @@ -515,9 +487,7 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.trace( - f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}" - ) + log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") await self.crawl_task log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") @@ -529,11 +499,11 @@ class Incidents(Cog): return if not is_incident(message): - log.debug("Ignoring event for a non-incident message.") + log.debug("Ignoring event for a non-incident message") return await self.process_event(str(payload.emoji), message, payload.member) - log.trace("Releasing event lock.") + log.trace("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 23578ae0381b0d8d81b7be9d8eb3bc86a1557e0b Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:19:06 +0530 Subject: Don't allow more than 10 embeds per report. If more than 10 embeds found, just get the first 10 and ignore the rest. --- bot/exts/moderation/incidents.py | 85 +++++++++++++++------------------------- 1 file changed, 32 insertions(+), 53 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index c988c45bb..032c15ca2 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -9,7 +9,6 @@ from enum import Enum import discord from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter -from more_itertools.recipes import grouper from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -197,15 +196,15 @@ async def add_signals(incident: discord.Message) -> None: return -async def extract_message_links(message: discord.Message) -> t.Optional[list]: +async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optional[list]: """ Checks if there's any message links in the text content. Then passes the the message_link into `make_message_link_embed` to format a embed for it containing information about the link. - As discord only allows a max of 10 embeds in a single webhook we need to - group the embeds into group of 10 and then return the list. + As discord only allows a max of 10 embeds in a single webhook, just send the + first 10 embeds and don't care about the rest. If no links are found for the message, it logs a trace statement. """ @@ -214,12 +213,10 @@ async def extract_message_links(message: discord.Message) -> t.Optional[list]: if message_links: embeds = [] for message_link in message_links: - ctx = await message.bot.get_context(message) + ctx = await bot.get_context(message) embeds.append(await make_message_link_embed(ctx, message_link[0])) - webhook_embed_list = list(grouper(embeds, 10)) - - return webhook_embed_list + return embeds[:10] log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." @@ -240,6 +237,7 @@ class Incidents(Cog): * See: `crawl_incidents` On message: + * Run message through `extract_message_links` and send them into the channel * Add `Signal` member emoji if message qualifies as an incident * Ignore messages starting with # * Use this if verbal communication is necessary @@ -253,18 +251,13 @@ class Incidents(Cog): * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message + * Search the cache for the webhook message for this message, if found delete it. * See: `on_raw_reaction_add` Please refer to function docstrings for implementation details. """ - # This dictionary maps a incident message to the message link embeds(s) sent by it - # - # Discord doesn't allow more than 10 embeds to be sent in a single webhook message - # hence the embeds need to be broken into groups of 10. Since we have multiple embeds - # and RedisCache doesn't allow storing lists, we need to join the list with commas to - # make it a string and then store it. - # + # This dictionary maps a incident message to the message link embeds sent by it # RedisCache[discord.Message.id, str] message_link_embeds_cache = RedisCache() @@ -426,7 +419,7 @@ class Incidents(Cog): log.trace("Deletion was confirmed") # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embeds(incident) + await self.delete_msg_link_embed(incident) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -515,7 +508,7 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is a incident. """ if is_incident(message): - webhook_embed_list = await extract_message_links(message) + webhook_embed_list = await extract_message_links(message, self.bot) webhook = await self.bot.fetch_webhook(Webhooks.incidents) await self.send_webhooks(webhook_embed_list, message, webhook) @@ -535,11 +528,11 @@ class Incidents(Cog): """ if is_incident(msg_before): if msg_before.id in self.message_link_embeds_cache.items: - # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embeds(msg_before) + # Deletes the message link embed found in cache from the channel and cache. + await self.delete_msg_link_embed(msg_before) if is_incident(msg_after): - webhook_embed_list = await extract_message_links(msg_after) + webhook_embed_list = await extract_message_links(msg_after, self.bot) webhook = await self.bot.fetch_webhook(Webhooks.incidents) await self.send_webhooks(webhook_embed_list, msg_after, webhook) @@ -548,19 +541,19 @@ class Incidents(Cog): @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """ - Deletes the message link embeds found in cache from the channel and cache if the message + Deletes the message link embed found in cache from the channel and cache if the message is a incident and is found in msg link embeds cache. """ if is_incident(message): if message.id in self.message_link_embeds_cache.items: - await self.delete_msg_link_embeds(message) + await self.delete_msg_link_embed(message) async def send_webhooks( self, webhook_embed_list: t.List, message: discord.Message, webhook: discord.Webhook, - ) -> t.List[int]: + ) -> t.Optional[int]: """ Send Message Link Embeds to #incidents channel. @@ -570,49 +563,35 @@ class Incidents(Cog): After sending each webhook it maps the `message.id` to the `webhook_msg_ids` IDs in the async redis-cache. """ - webhook_msg_ids = [] try: - for x, embed in enumerate(webhook_embed_list): - webhook_msg = await webhook.send( - embeds=[x for x in embed if x is not None], - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True, - ) - webhook_msg_ids.append(webhook_msg.id) - log.trace( - f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." - ) + webhook_msg = await webhook.send( + embeds=[x for x in webhook_embed_list if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True, + ) + log.trace(f"Message Link Embed sent successfully.") except discord.DiscordException: log.exception( - f"Failed to send message link embeds {message.id} to #incidents." + f"Failed to send message link embed {message.id} to #incidents." ) else: - await self.message_link_embeds_cache.set( - message.id, ",".join(map(str, webhook_msg_ids)) - ) - log.trace("Message Link Embeds Sent successfully!") - - return webhook_msg_ids + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + log.trace("Message Link Embed Sent successfully!") + return webhook_msg.id - async def delete_msg_link_embeds(self, message: discord.Message) -> None: - """Delete discord message links message found in cache for `message`.""" + async def delete_msg_link_embed(self, message: discord.Message) -> None: + """Delete discord message link message found in cache for `message`.""" log.trace("Deleting discord links webhook message.") + webhook_msg_id = await self.message_link_embeds_cache.get(message.id) - webhook_msg_ids = await self.message_link_embeds_cache.get(message.id) - - if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(",") + if webhook_msg_id: webhook = await self.bot.fetch_webhook(Webhooks.incidents) - - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + await webhook.delete_message(webhook_msg_id) await self.message_link_embeds_cache.delete(message.id) - log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 691a63c8dcfee89f2cf8e5d2c9456b84789dfc9a Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:22:00 +0530 Subject: Use str() rather than f string for single variable. Makes the intent much more clear. --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 032c15ca2..df8d08509 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -156,7 +156,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message = await message_convert_object.convert(ctx, message_link) except discord.DiscordException as e: - embed.title = f"{e}" + embed.title = str(e) embed.colour = Colours.soft_red else: -- cgit v1.2.3 From 00175a55784603c6030e83f2099e7e7daba02654 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:27:02 +0530 Subject: Make incidents channel webhook a cog level attribute This would not fetch it everytime. --- bot/exts/moderation/incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index df8d08509..a2548daca 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -265,6 +265,9 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot + # Webhook to send message link embeds in #incidents + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) @@ -509,8 +512,7 @@ class Incidents(Cog): """ if is_incident(message): webhook_embed_list = await extract_message_links(message, self.bot) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await self.send_webhooks(webhook_embed_list, message, webhook) + await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) await add_signals(message) @@ -533,8 +535,7 @@ class Incidents(Cog): if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await self.send_webhooks(webhook_embed_list, msg_after, webhook) + await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) await add_signals(msg_after) @@ -588,8 +589,7 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(message.id) if webhook_msg_id: - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await webhook.delete_message(webhook_msg_id) + await self.incidents_webhook.delete_message(webhook_msg_id) await self.message_link_embeds_cache.delete(message.id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 593e5fe3172ac36de1f4875ce1eb734734a15d70 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:53:07 +0530 Subject: On msg edits, edit the msg link embed rather than deleting it --- bot/exts/moderation/incidents.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a2548daca..f6607e651 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -528,10 +528,15 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - if is_incident(msg_before): - if msg_before.id in self.message_link_embeds_cache.items: - # Deletes the message link embed found in cache from the channel and cache. - await self.delete_msg_link_embed(msg_before) + + webhook_embed_list = await extract_message_links(msg_after, self.bot) + webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) + + if webhook_msg_id: + await self.incidents_webhook.edit_message( + message_id=webhook_msg_id, + embeds=[x for x in webhook_embed_list if x is not None], + ) if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) -- cgit v1.2.3 From 91ffa412294a8bf63da132df19557fec54b02a00 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:54:24 +0530 Subject: Use tasks to fetch incidents channel webhook. --- bot/exts/moderation/incidents.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index f6607e651..deaabcfa0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -265,12 +265,16 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - # Webhook to send message link embeds in #incidents - self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + self.bot.loop.create_task(self.get_webhook()) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) + async def get_webhook(self) -> None: + """Fetch and store message link embeds webhook, present in #incidents channel.""" + await self.bot.wait_until_guild_available() + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + async def crawl_incidents(self) -> None: """ Crawl #incidents and add missing emoji where necessary. @@ -555,10 +559,10 @@ class Incidents(Cog): await self.delete_msg_link_embed(message) async def send_webhooks( - self, - webhook_embed_list: t.List, - message: discord.Message, - webhook: discord.Webhook, + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook, ) -> t.Optional[int]: """ Send Message Link Embeds to #incidents channel. -- cgit v1.2.3 From a9ac92b19d6b4f562383e9eeab09eec8ef063d44 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 07:02:19 +0530 Subject: Do required flake8 changes in docstrings. --- bot/exts/moderation/incidents.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index deaabcfa0..7ef7eb327 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -508,6 +508,8 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: """ + Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. + If the message (`message`) is a incident then run it through `extract_message_links` to get all the message link embeds (embeds which contain information about that particular link), this message link embeds are then sent into the channel. @@ -523,6 +525,8 @@ class Incidents(Cog): @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """ + Pass `msg_after` to `extract_message_links` and edit `msg_before` webhook msg. + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the webhook message for that particular link from the channel. @@ -532,7 +536,6 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - webhook_embed_list = await extract_message_links(msg_after, self.bot) webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) @@ -551,8 +554,9 @@ class Incidents(Cog): @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """ - Deletes the message link embed found in cache from the channel and cache if the message - is a incident and is found in msg link embeds cache. + Delete message link embeds for `message`. + + Search through the cache for message, if found delete it from cache and channel. """ if is_incident(message): if message.id in self.message_link_embeds_cache.items: @@ -580,7 +584,7 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - log.trace(f"Message Link Embed sent successfully.") + log.trace("Message Link Embed sent successfully.") except discord.DiscordException: log.exception( -- cgit v1.2.3 From 0f92fe11ffc8c6262c62bd7e9d0c4c81bd8da6f5 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 19 Apr 2021 05:40:45 +0530 Subject: Don't send errors, instead log them. Errors shouldn't be sent in #incidents. Instead, log them with log.exception and make the function return. --- bot/exts/moderation/incidents.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7ef7eb327..a259db10d 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -140,7 +140,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: +async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -156,8 +156,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message = await message_convert_object.convert(ctx, message_link) except discord.DiscordException as e: - embed.title = str(e) - embed.colour = Colours.soft_red + log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: text = message.content @@ -169,8 +168,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" "\n" ) - - return embed + return embed async def add_signals(incident: discord.Message) -> None: @@ -214,7 +212,9 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona embeds = [] for message_link in message_links: ctx = await bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link[0])) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) return embeds[:10] -- cgit v1.2.3 From 138dc2e5039fce0b267c9d47db6e387a832d3df0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 19 Apr 2021 06:03:53 +0530 Subject: Bug fixes - `await` message link embeds cache get - don't double send webhook embeds (edit, send) on message edits --- bot/exts/moderation/incidents.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a259db10d..413c9bcf9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -517,11 +517,12 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is a incident. """ if is_incident(message): - webhook_embed_list = await extract_message_links(message, self.bot) - await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) - await add_signals(message) + webhook_embed_list = await extract_message_links(message, self.bot) + if webhook_embed_list: + await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) + @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """ @@ -536,20 +537,18 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - webhook_embed_list = await extract_message_links(msg_after, self.bot) - webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) - - if webhook_msg_id: - await self.incidents_webhook.edit_message( - message_id=webhook_msg_id, - embeds=[x for x in webhook_embed_list if x is not None], - ) - if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) - await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) + webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) - await add_signals(msg_after) + if webhook_msg_id: + await self.incidents_webhook.edit_message( + message_id=webhook_msg_id, + embeds=[x for x in webhook_embed_list if x is not None], + ) + return + + await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: @@ -559,8 +558,7 @@ class Incidents(Cog): Search through the cache for message, if found delete it from cache and channel. """ if is_incident(message): - if message.id in self.message_link_embeds_cache.items: - await self.delete_msg_link_embed(message) + await self.delete_msg_link_embed(message) async def send_webhooks( self, -- cgit v1.2.3 From b116d3c47d9c5c8e99b2557b37d0e402652b5ef3 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 21 Apr 2021 15:09:29 +0530 Subject: Rework message link embed. - Instead of default black colour, use gold to give it some shine! - Mention the channel also in the channel field. - Add message ID in footer, so it is easy to figure out for which message link is that embed. --- bot/exts/moderation/incidents.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 413c9bcf9..aebf22d00 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -149,8 +149,6 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ - embed = discord.Embed() - try: message_convert_object = MessageConverter() message = await message_convert_object.convert(ctx, message_link) @@ -162,12 +160,16 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional text = message.content channel = message.channel - embed.description = ( - f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" - "\n" + embed = discord.Embed( + colour=discord.Colour.gold(), + description=( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** <#{channel.id}> ({channel.category}/#{channel.name})\n" + f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" + ) ) + embed.set_footer(text=f"Message ID: {message.id}") + return embed -- cgit v1.2.3 From 5f57103b9dea3af864c916a24a8ffcc61d0106dc Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:08:57 +0530 Subject: Remove redundant code --- bot/exts/moderation/incidents.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index aebf22d00..24cd21406 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -150,8 +150,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Content: This is a very important message! """ try: - message_convert_object = MessageConverter() - message = await message_convert_object.convert(ctx, message_link) + message = await MessageConverter().convert(ctx, message_link) except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") @@ -164,7 +163,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" - f"**Channel:** <#{channel.id}> ({channel.category}/#{channel.name})\n" + f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" ) ) @@ -584,7 +583,6 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - log.trace("Message Link Embed sent successfully.") except discord.DiscordException: log.exception( -- cgit v1.2.3 From e95b139593b9014638c187402343141967aba765 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:13:19 +0530 Subject: Appy requested grammar changes. Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 24cd21406..b174ce668 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -258,8 +258,8 @@ class Incidents(Cog): Please refer to function docstrings for implementation details. """ - # This dictionary maps a incident message to the message link embeds sent by it - # RedisCache[discord.Message.id, str] + # This dictionary maps an incident report message to the message link embed's ID + # RedisCache[discord.Message.id, discord.Message.id] message_link_embeds_cache = RedisCache() def __init__(self, bot: Bot) -> None: @@ -511,11 +511,11 @@ class Incidents(Cog): """ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. - If the message (`message`) is a incident then run it through `extract_message_links` + If the message (`message`) is an incident report, then run it through `extract_message_links` to get all the message link embeds (embeds which contain information about that particular - link), this message link embeds are then sent into the channel. + link).These message link embeds are then sent into the channel. - Also passes the message into `add_signals` if the message is a incident. + Also passes the message into `add_signals` if the message is an incident. """ if is_incident(message): await add_signals(message) -- cgit v1.2.3 From 1fa3ce5acc98dd3bea77881dfea1fdc0001feccb Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:15:30 +0530 Subject: Rename 'send_webhooks' to 'send_message_link_embed' Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b174ce668..da349f654 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -522,7 +522,7 @@ class Incidents(Cog): webhook_embed_list = await extract_message_links(message, self.bot) if webhook_embed_list: - await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) + await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: @@ -549,7 +549,7 @@ class Incidents(Cog): ) return - await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) + await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: @@ -561,7 +561,7 @@ class Incidents(Cog): if is_incident(message): await self.delete_msg_link_embed(message) - async def send_webhooks( + async def send_message_link_embeds( self, webhook_embed_list: t.List, message: discord.Message, -- cgit v1.2.3 From b0c4cdbd3328e46f5a1d6dd6be3600e20e7f19aa Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:17:39 +0530 Subject: Remove leading whitespace from msg link embed content --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index da349f654..9d3b0fe6f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -164,7 +164,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" + f"**Content:** {textwrap.shorten(text.lstrip(), 300, placeholder='...')}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From f74e894d3ba2d26130e31ba13a8a7ced2b63af4e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:20:24 +0530 Subject: Handle discord.errors.NotFound while deleting msg link webhook embeds --- bot/exts/moderation/incidents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9d3b0fe6f..840327cb6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -600,7 +600,10 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(message.id) if webhook_msg_id: - await self.incidents_webhook.delete_message(webhook_msg_id) + try: + await self.incidents_webhook.delete_message(webhook_msg_id) + except discord.errors.NotFound: + log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.") await self.message_link_embeds_cache.delete(message.id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 5ce51115f19f7ce13802701dc58d508ab5eb69f8 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 27 Apr 2021 06:27:16 +0530 Subject: Fix truncation bug When you take a long message, just one word of 400 A's then the truncated wouldn't be able to handle it properly and just return the placeholder. This is a bug in the textwrap.shorten function. To solve this, I went the long way to use slicing on the list. This commit seems to have resolved the bug. --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 840327cb6..235f7a0f7 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -156,15 +156,16 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: - text = message.content + text = message.content.lstrip() channel = message.channel - + shortened_text = text[:300] + (text[300:] and '...') + embed = discord.Embed( colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {textwrap.shorten(text.lstrip(), 300, placeholder='...')}\n" + f"**Content:** {shortened_text}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From a068ce561024ddb60677e6b6d6887102567dcf2e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Sat, 1 May 2021 07:33:24 +0530 Subject: Write tests for this feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In short, I have written two tests, one which tests the whether `extract_message_links` is called on message edits or not. And the second one to test the regex of `extract_message_links` and assert the message link embeds sent by it. Special thanks to kwzrd💜#1198 for helping me out with it. --- bot/exts/moderation/incidents.py | 5 +-- tests/bot/exts/moderation/test_incidents.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 235f7a0f7..a71cea45f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,7 +1,6 @@ import asyncio import logging import re -import textwrap import typing as t from datetime import datetime from enum import Enum @@ -159,7 +158,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional text = message.content.lstrip() channel = message.channel shortened_text = text[:300] + (text[300:] and '...') - + embed = discord.Embed( colour=discord.Colour.gold(), description=( @@ -591,7 +590,7 @@ class Incidents(Cog): ) else: - await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + await self.message_link_embeds_cache.set(int(message.id), int(webhook_msg.id)) log.trace("Message Link Embed Sent successfully!") return webhook_msg.id diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index c015951b3..4b2b652fc 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -3,6 +3,7 @@ import enum import logging import typing as t import unittest +from unittest import mock from unittest.mock import AsyncMock, MagicMock, call, patch import aiohttp @@ -11,6 +12,8 @@ from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents +from bot.exts.moderation.incidents import extract_message_links +from bot.utils.messages import format_user from tests.helpers import ( MockAsyncWebhook, MockAttachment, @@ -785,3 +788,64 @@ class TestOnMessage(TestIncidents): await self.cog_instance.on_message(MockMessage()) mock_add_signals.assert_not_called() + + +class TestMessageLinkEmbeds(TestIncidents): + """Tests for `extract_message_links` coroutine.""" + + async def extract_and_form_message_link_embeds(self): + """ + Extract message links from a mocked message and form the message link embed. + + Considers all types of message links, discord supports. + """ + self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + msg = MockMessage(id=555, content="Hello, World!" * 3000) + msg.channel.mention = "#lemonade-stand" + + msg_links = [ + # Valid Message links + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + + # Invalid Message links + f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}", + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}", + ] + + incident_msg = MockMessage( + id=777, + content=f"I would like to report the following messages, " + f"as they break our rules: \n{', '.join(msg_links)}" + ) + + embeds = await extract_message_links(incident_msg, self.cog_instance.bot) + description = ( + f"**Author:** {format_user(msg.author)}\n" + f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" + f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" + ) + + # Check number of embeds returned with number of valid links + self.assertEqual( + self, len(embeds), 2 + ) + + # Check for the embed descriptions + for embed in embeds: + self.assertEqual( + self, embed.description, description + ) + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_incident_message_edit(self): + """Edit the incident message and check whether `extract_message_links` is called or not.""" + self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook + + edited_msg = MockMessage(id=123) + with patch("bot.exts.moderation.incidents.extract_message_links", AsyncMock()) as mock_extract_message_links: + await self.cog_instance.on_message_edit(MockMessage(id=123), edited_msg) + + mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From c8a8cec90b376ea5e2a191957b82bab8d519ff00 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 3 May 2021 10:13:41 +0530 Subject: Rework text shortner. Explanation: It is unnecessary to show 300 characters, when there is only one word which is so long, so if there is only one word in the text, it would be truncated to 50 words. Also in some cases, there are messages of many lines with 1 word on each line(say), this would again make the embed big and polluting, so it would limit the number of lines to a maximum of 3. Rest of the feature is the same as before. This implementation has been inspired from the `format_output` function of snekbox cog. --- bot/exts/moderation/incidents.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a71cea45f..18c229644 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -139,6 +139,23 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) +async def shorten_text(text: str) -> str: + lines = text.count("\n") + if lines > 3: + text = "\n".join(line for line in text.split('\n')[:3]) + if len(text) >= 300: + text = f"{text[:300]}\n... (truncated - too long, too many lines)" + else: + text = f"{text}\n... (truncated - too many lines)" + elif len(text) >= 300: + if text.count(" ") < 1: + text = f"{text[:50]}\n... (truncated - single word)" + else: + text = f"{text[:300]}\n... (truncated - too long)" + + return text + + async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -149,22 +166,20 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Content: This is a very important message! """ try: - message = await MessageConverter().convert(ctx, message_link) + message: discord.Message = await MessageConverter().convert(ctx, message_link) except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: - text = message.content.lstrip() channel = message.channel - shortened_text = text[:300] + (text[300:] and '...') embed = discord.Embed( colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {shortened_text}\n" + f"**Content:** {await shorten_text(message.content)}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From 95d14d30a29aeeb2ced0a90e6e01cb9fd0ad4f6e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Thu, 6 May 2021 09:22:05 +0530 Subject: (incidents): Refactor text shortner --- bot/exts/moderation/incidents.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 18c229644..09712f5a0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -140,18 +140,21 @@ def has_signals(message: discord.Message) -> bool: async def shorten_text(text: str) -> str: + """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" + original_length = len(text) lines = text.count("\n") + # Limit to a maximum of three lines if lines > 3: text = "\n".join(line for line in text.split('\n')[:3]) - if len(text) >= 300: - text = f"{text[:300]}\n... (truncated - too long, too many lines)" - else: - text = f"{text}\n... (truncated - too many lines)" - elif len(text) >= 300: - if text.count(" ") < 1: - text = f"{text[:50]}\n... (truncated - single word)" - else: - text = f"{text[:300]}\n... (truncated - too long)" + # If it is a single word, then truncate it to 50 characters + if text.count(" ") < 1: + text = text[:50] + # Truncate text to a maximum of 300 characters + if len(text) > 300: + text = text[:300] + # Add placeholder if the text was shortened + if len(text) < original_length: + text += "..." return text @@ -179,9 +182,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {await shorten_text(message.content)}\n" ) ) + embed.add_field( + name="Content", + value=await shorten_text(message.content) + ) embed.set_footer(text=f"Message ID: {message.id}") return embed -- cgit v1.2.3 From 86988ac67ceaf6fb6fb5cfada0d964fef4b591e3 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Fri, 7 May 2021 09:36:49 +0530 Subject: (incidents): Add test for text shortner Pass all 3 cases of text shortening to the test case and test them, the cases being: i. If the message is just one word, then shorten to 50 characters. ii. Maximum lines being 3. iii. Maximum characters being 300. This commit also removes a misc bug, of passing self, while asserting equal. --- bot/exts/moderation/incidents.py | 4 ++-- tests/bot/exts/moderation/test_incidents.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 09712f5a0..22b50625a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -139,7 +139,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def shorten_text(text: str) -> str: +def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) lines = text.count("\n") @@ -186,7 +186,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional ) embed.add_field( name="Content", - value=await shorten_text(message.content) + value=shorten_text(message.content) ) embed.set_footer(text=f"Message ID: {message.id}") diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 4b2b652fc..3c5d8f47d 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -12,7 +12,6 @@ from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents -from bot.exts.moderation.incidents import extract_message_links from bot.utils.messages import format_user from tests.helpers import ( MockAsyncWebhook, @@ -793,6 +792,19 @@ class TestOnMessage(TestIncidents): class TestMessageLinkEmbeds(TestIncidents): """Tests for `extract_message_links` coroutine.""" + async def test_shorten_text(self): + """Test all cases of text shortening by mocking messages.""" + tests = { + "thisisasingleword"*10: ('thisisasingleword'*10)[:50]+"...", + "\n".join("Lets make a new line test".split()): "Lets\nmake\na"+"...", + 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' + } + + for test, value in tests.items(): + self.assertEqual( + str(incidents.shorten_text(test)), value + ) + async def extract_and_form_message_link_embeds(self): """ Extract message links from a mocked message and form the message link embed. @@ -821,7 +833,7 @@ class TestMessageLinkEmbeds(TestIncidents): f"as they break our rules: \n{', '.join(msg_links)}" ) - embeds = await extract_message_links(incident_msg, self.cog_instance.bot) + embeds = await incidents.extract_message_links(incident_msg, self.cog_instance.bot) description = ( f"**Author:** {format_user(msg.author)}\n" f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" @@ -829,15 +841,11 @@ class TestMessageLinkEmbeds(TestIncidents): ) # Check number of embeds returned with number of valid links - self.assertEqual( - self, len(embeds), 2 - ) + self.assertEqual(len(embeds), 2) # Check for the embed descriptions for embed in embeds: - self.assertEqual( - self, embed.description, description - ) + self.assertEqual(embed.description, description) @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): -- cgit v1.2.3 From 24c7a975cf18b80ae4bb6d65f5a4950bae0ca4cb Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 10 May 2021 10:02:22 +0530 Subject: (incidents): Use subtests for test_shorten_text --- tests/bot/exts/moderation/test_incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3c5d8f47d..875b76057 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -800,10 +800,10 @@ class TestMessageLinkEmbeds(TestIncidents): 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' } - for test, value in tests.items(): - self.assertEqual( - str(incidents.shorten_text(test)), value - ) + for content, expected_conversion in tests.items(): + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = incidents.shorten_text(content) + self.assertEqual(conversion, expected_conversion) async def extract_and_form_message_link_embeds(self): """ -- cgit v1.2.3 From fc9a9d2cd01530444804b271ed00432cacf85353 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 10 May 2021 10:07:48 +0530 Subject: (incidents):Log with error if webhook not found --- bot/exts/moderation/incidents.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 22b50625a..7d0984bd1 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -297,6 +297,9 @@ class Incidents(Cog): await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + if not self.incidents_webhook: + log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.") + async def crawl_incidents(self) -> None: """ Crawl #incidents and add missing emoji where necessary. -- cgit v1.2.3 From 23126ee86d2aa4d9357c41247faf46e1b2a8d138 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:39:19 +0530 Subject: Only process the first 10 message links --- bot/exts/moderation/incidents.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7d0984bd1..9ce892024 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -232,14 +232,12 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona if message_links: embeds = [] - for message_link in message_links: + for message_link in message_links[:10]: ctx = await bot.get_context(message) embed = await make_message_link_embed(ctx, message_link[0]) if embed: embeds.append(embed) - return embeds[:10] - log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." ) -- cgit v1.2.3 From fc8c0c121fe853baa3ee4ecd760229eac6689387 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:44:31 +0530 Subject: Apply requested changes to doc strings --- bot/exts/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9ce892024..950d419c0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -271,7 +271,7 @@ class Incidents(Cog): * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message - * Search the cache for the webhook message for this message, if found delete it. + * Delete quotation message if cached * See: `on_raw_reaction_add` Please refer to function docstrings for implementation details. @@ -533,9 +533,9 @@ class Incidents(Cog): """ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. - If the message (`message`) is an incident report, then run it through `extract_message_links` - to get all the message link embeds (embeds which contain information about that particular - link).These message link embeds are then sent into the channel. + If `message` is an incident report, then run it through `extract_message_links` to get all + the message link embeds (embeds which contain information about that particular link). + These message link embeds are then sent into the channel. Also passes the message into `add_signals` if the message is an incident. """ -- cgit v1.2.3 From 19112affa86ceb2fbe55e0cf751ac675f24d725e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:46:35 +0530 Subject: Use better variable names This commit also adds a line which was got removed by mistake earlier. --- bot/exts/moderation/incidents.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 950d419c0..05c2ad6c9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -238,6 +238,8 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona if embed: embeds.append(embed) + return embeds + log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." ) @@ -567,7 +569,7 @@ class Incidents(Cog): if webhook_msg_id: await self.incidents_webhook.edit_message( message_id=webhook_msg_id, - embeds=[x for x in webhook_embed_list if x is not None], + embeds=[embed for embed in webhook_embed_list if embed is not None], ) return @@ -600,7 +602,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[x for x in webhook_embed_list if x is not None], + embeds=[embed for embed in webhook_embed_list if embed is not None], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From aa620fefd1dbf9f5cda19a72bf29483a61aa2a93 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:29:39 +0530 Subject: Make `extract_message_links` an instance method Since it was used cog's state (`self.bot`), it would be better to move it to the cog. --- bot/exts/moderation/incidents.py | 61 ++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 05c2ad6c9..197842034 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -216,35 +216,6 @@ async def add_signals(incident: discord.Message) -> None: return -async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optional[list]: - """ - Checks if there's any message links in the text content. - - Then passes the the message_link into `make_message_link_embed` to format a - embed for it containing information about the link. - - As discord only allows a max of 10 embeds in a single webhook, just send the - first 10 embeds and don't care about the rest. - - If no links are found for the message, it logs a trace statement. - """ - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - - if message_links: - embeds = [] - for message_link in message_links[:10]: - ctx = await bot.get_context(message) - embed = await make_message_link_embed(ctx, message_link[0]) - if embed: - embeds.append(embed) - - return embeds - - log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." - ) - - class Incidents(Cog): """ Automation for the #incidents channel. @@ -544,7 +515,7 @@ class Incidents(Cog): if is_incident(message): await add_signals(message) - webhook_embed_list = await extract_message_links(message, self.bot) + webhook_embed_list = await self.extract_message_links(message) if webhook_embed_list: await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @@ -563,7 +534,7 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ if is_incident(msg_after): - webhook_embed_list = await extract_message_links(msg_after, self.bot) + webhook_embed_list = await self.extract_message_links(msg_after) webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) if webhook_msg_id: @@ -585,6 +556,34 @@ class Incidents(Cog): if is_incident(message): await self.delete_msg_link_embed(message) + async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: + """ + Checks if there's any message links in the text content. + + Then passes the the message_link into `make_message_link_embed` to format a + embed for it containing information about the link. + + As discord only allows a max of 10 embeds in a single webhook, just send the + first 10 embeds and don't care about the rest. + + If no links are found for the message, it logs a trace statement. + """ + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + + if message_links: + embeds = [] + for message_link in message_links[:10]: + ctx = await self.bot.get_context(message) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) + + return embeds + + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) + async def send_message_link_embeds( self, webhook_embed_list: t.List, -- cgit v1.2.3 From 682693bc07960db186cef95b0188031f934a360c Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:35:40 +0530 Subject: Delete msg link embed if no link on edit Earlier, if we edited a message which contained message links originally but not now, then the webhook message wouldn't get deleted. This commits fixes that bug. --- bot/exts/moderation/incidents.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 197842034..7aad1df35 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -537,6 +537,10 @@ class Incidents(Cog): webhook_embed_list = await self.extract_message_links(msg_after) webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) + if not webhook_embed_list: + await self.delete_msg_link_embed(msg_after) + return + if webhook_msg_id: await self.incidents_webhook.edit_message( message_id=webhook_msg_id, -- cgit v1.2.3 From ecfcc902fc619c2f07c449b40c4373a61b5abaf7 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:55:19 +0530 Subject: Use `on_raw_message_edit` Originally it was using `on_message_edit` which would have failed if the message was not in the bot' cache. Therefore we would have to use a raw listener. --- bot/exts/moderation/incidents.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7aad1df35..0da4acaa2 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -520,9 +520,12 @@ class Incidents(Cog): await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @Cog.listener() - async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: + async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None: """ - Pass `msg_after` to `extract_message_links` and edit `msg_before` webhook msg. + Pass processed `payload` to `extract_message_links` and edit `msg_before` webhook msg. + + Fetch the message found in payload, if not found i.e. the message got deleted then delete its + webhook message and return. Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the webhook message for that particular link from the channel. @@ -533,9 +536,15 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ + try: + channel = self.bot.get_channel(int(payload.data["channel_id"])) + msg_after = await channel.fetch_message(payload.message_id) + except discord.NotFound: # Was deleted before we got the event + return + if is_incident(msg_after): webhook_embed_list = await self.extract_message_links(msg_after) - webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) + webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) if not webhook_embed_list: await self.delete_msg_link_embed(msg_after) -- cgit v1.2.3 From 44d5481d23cf819e347d3940812be3450ead1934 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 10:07:01 +0530 Subject: Use raw message delete listener --- bot/exts/moderation/incidents.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0da4acaa2..7ef4af3df 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -540,6 +540,7 @@ class Incidents(Cog): channel = self.bot.get_channel(int(payload.data["channel_id"])) msg_after = await channel.fetch_message(payload.message_id) except discord.NotFound: # Was deleted before we got the event + await self.delete_msg_link_embed(payload.message_id) return if is_incident(msg_after): @@ -547,7 +548,7 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) if not webhook_embed_list: - await self.delete_msg_link_embed(msg_after) + await self.delete_msg_link_embed(msg_after.id) return if webhook_msg_id: @@ -560,14 +561,13 @@ class Incidents(Cog): await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: + async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: """ - Delete message link embeds for `message`. + Delete message link embeds for `payload.message_id`. Search through the cache for message, if found delete it from cache and channel. """ - if is_incident(message): - await self.delete_msg_link_embed(message) + await self.delete_msg_link_embed(payload.message_id) async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: """ @@ -630,10 +630,10 @@ class Incidents(Cog): log.trace("Message Link Embed Sent successfully!") return webhook_msg.id - async def delete_msg_link_embed(self, message: discord.Message) -> None: + async def delete_msg_link_embed(self, message_id: int) -> None: """Delete discord message link message found in cache for `message`.""" log.trace("Deleting discord links webhook message.") - webhook_msg_id = await self.message_link_embeds_cache.get(message.id) + webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id)) if webhook_msg_id: try: @@ -641,7 +641,7 @@ class Incidents(Cog): except discord.errors.NotFound: log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.") - await self.message_link_embeds_cache.delete(message.id) + await self.message_link_embeds_cache.delete(message_id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 40a57a1dad45f0b32f2c5137e9c36d9c6df183fd Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 11:19:59 +0530 Subject: Update tests for message link embeds This commit updates the test in accordance with 0b35f2a and 0c5561d. --- bot/exts/moderation/incidents.py | 2 +- tests/bot/exts/moderation/test_incidents.py | 42 +++++++++++++++++++---------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7ef4af3df..97bb32591 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -422,7 +422,7 @@ class Incidents(Cog): log.trace("Deletion was confirmed") # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embed(incident) + await self.delete_msg_link_embed(incident.id) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 875b76057..6e97d31af 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -833,27 +833,41 @@ class TestMessageLinkEmbeds(TestIncidents): f"as they break our rules: \n{', '.join(msg_links)}" ) - embeds = await incidents.extract_message_links(incident_msg, self.cog_instance.bot) - description = ( - f"**Author:** {format_user(msg.author)}\n" - f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" - f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" - ) + with patch( + "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() + ) as mock_extract_message_links: + embeds = mock_extract_message_links(incident_msg) + description = ( + f"**Author:** {format_user(msg.author)}\n" + f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" + f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" + ) - # Check number of embeds returned with number of valid links - self.assertEqual(len(embeds), 2) + # Check number of embeds returned with number of valid links + self.assertEqual(len(embeds), 2) - # Check for the embed descriptions - for embed in embeds: - self.assertEqual(embed.description, description) + # Check for the embed descriptions + for embed in embeds: + self.assertEqual(embed.description, description) @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): """Edit the incident message and check whether `extract_message_links` is called or not.""" self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook - edited_msg = MockMessage(id=123) - with patch("bot.exts.moderation.incidents.extract_message_links", AsyncMock()) as mock_extract_message_links: - await self.cog_instance.on_message_edit(MockMessage(id=123), edited_msg) + text_channel = MockTextChannel() + self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) + text_channel.fetch_message = AsyncMock(return_value=MockMessage()) + + payload = AsyncMock( + discord.RawMessageUpdateEvent, + channel_id=123, + message_id=456 + ) + + with patch( + "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() + ) as mock_extract_message_links: + await self.cog_instance.on_raw_message_edit(payload) mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From 43bed60ff788eefba704318f8b18e0b3f8b5eb4c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 14:26:14 +0530 Subject: Mock id,content attribute rather than type casting --- bot/exts/moderation/incidents.py | 4 ++-- tests/bot/exts/moderation/test_incidents.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 97bb32591..8d255071a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -537,7 +537,7 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ try: - channel = self.bot.get_channel(int(payload.data["channel_id"])) + channel = self.bot.get_channel(payload.channel_id) msg_after = await channel.fetch_message(payload.message_id) except discord.NotFound: # Was deleted before we got the event await self.delete_msg_link_embed(payload.message_id) @@ -626,7 +626,7 @@ class Incidents(Cog): ) else: - await self.message_link_embeds_cache.set(int(message.id), int(webhook_msg.id)) + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) log.trace("Message Link Embed Sent successfully!") return webhook_msg.id diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 6e97d31af..06eafdde3 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -853,11 +853,12 @@ class TestMessageLinkEmbeds(TestIncidents): @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): """Edit the incident message and check whether `extract_message_links` is called or not.""" - self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook + self.cog_instance.incidents_webhook = MockAsyncWebhook(id=101) # Patch in our webhook + self.cog_instance.incidents_webhook.send = AsyncMock(return_value=MockMessage(id=191)) - text_channel = MockTextChannel() + text_channel = MockTextChannel(id=123) self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) - text_channel.fetch_message = AsyncMock(return_value=MockMessage()) + text_channel.fetch_message = AsyncMock(return_value=MockMessage(id=777, content="Did jason just screw up?")) payload = AsyncMock( discord.RawMessageUpdateEvent, -- cgit v1.2.3 From 6965c0868bb6230c35eca9dac4541e5e904b7575 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 15:41:40 +0530 Subject: Correct log trace link to show the correct behaviour --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 8d255071a..2de7dd666 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -594,7 +594,7 @@ class Incidents(Cog): return embeds log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." + f"No message links detected on incident message with id {message.id}." ) async def send_message_link_embeds( -- cgit v1.2.3 From b01cccec51d5c9df298a0380ad2ab516ee219c3d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 15:43:59 +0530 Subject: Remove unnecessary check for embed when sending --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 2de7dd666..dfc2e0bb0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -614,7 +614,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[embed for embed in webhook_embed_list if embed is not None], + embeds=[embed for embed in webhook_embed_list], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From 1b52ccf00df8550b0e5df02d350fb8e0baef935d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 21 Aug 2021 07:26:11 +0530 Subject: Handle message not found specially Originally message not found would be passed into discord.Exceptions and then would be logged which was unnecessary for a `MessageNotFound` error. Now this has been handled, if the bot receives a deleted message it would look through the last 100 messages of mod_logs channel to check if a log entry exists for that message. If one exists, then the incident embed would have this log entry message linked to it, if it doesn't it would send a message not found error embed. In future, we could maybe do the modlog deleted message entry finding via the python discord API, rather than using hard coded values and checking if they are existing in the log entry. --- bot/exts/moderation/incidents.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index dfc2e0bb0..58777f8cc 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -7,7 +7,7 @@ from enum import Enum import discord from async_rediscache import RedisCache -from discord.ext.commands import Cog, Context, MessageConverter +from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -168,9 +168,36 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ + embed = None + try: message: discord.Message = await MessageConverter().convert(ctx, message_link) + except MessageNotFound: + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() + + for log_entry in last_100_logs: + log_embed: discord.Embed = log_entry.embeds[0] + if ( + log_embed.author.name == "Message deleted" + and f"[Jump to message]({message_link})" in log_embed.description + ): + embed = discord.Embed( + colour=discord.Colour.dark_gold(), + title="Deleted Message Link", + description=( + f"Found <#{Channels.mod_log}> entry for deleted message: " + f"[Jump to message]({log_entry.jump_url})." + ) + ) + if not embed: + embed = discord.Embed( + colour=discord.Colour.red(), + title="Bad Message Link", + description=f"Message {message_link} not found." + ) + except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") @@ -190,7 +217,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional ) embed.set_footer(text=f"Message ID: {message.id}") - return embed + return embed async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 1724d323db69f2aebc9b74a28b6322ef5f784fe4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 22 Aug 2021 14:14:12 +0530 Subject: Sends msg embeds for helper readable msgs only --- bot/exts/moderation/incidents.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 58777f8cc..81a1f0721 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -10,7 +10,7 @@ from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -203,6 +203,13 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional else: channel = message.channel + helpers_role = message.guild.get_role(Roles.helpers) + if not channel.overwrites_for(helpers_role).read_messages: + log.info( + f"Helpers don't have read permissions in #{channel.name}," + " not sending message link embed for {message_link}" + ) + return embed = discord.Embed( colour=discord.Colour.gold(), @@ -641,7 +648,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[embed for embed in webhook_embed_list], + embeds=[embed for embed in webhook_embed_list if embed], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From 842cf60966e4a568b20961d350fb8ceaee6d8d96 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 31 Aug 2021 05:26:06 +0530 Subject: Goodbye enhanced incidents edits Was discussed with Mr.Webscale (joe), Xithrius in dev-voice --- bot/exts/moderation/incidents.py | 41 ----------------------------- tests/bot/exts/moderation/test_incidents.py | 23 ---------------- 2 files changed, 64 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 81a1f0721..70f5272e0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -553,47 +553,6 @@ class Incidents(Cog): if webhook_embed_list: await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) - @Cog.listener() - async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None: - """ - Pass processed `payload` to `extract_message_links` and edit `msg_before` webhook msg. - - Fetch the message found in payload, if not found i.e. the message got deleted then delete its - webhook message and return. - - Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the - webhook message for that particular link from the channel. - - If the message edit (`msg_after`) is a incident then run it through `extract_message_links` - to get all the message link embeds (embeds which contain information about that particular - link), this message link embeds are then sent into the channel. - - The edited message is also passed into `add_signals` if it is a incident message. - """ - try: - channel = self.bot.get_channel(payload.channel_id) - msg_after = await channel.fetch_message(payload.message_id) - except discord.NotFound: # Was deleted before we got the event - await self.delete_msg_link_embed(payload.message_id) - return - - if is_incident(msg_after): - webhook_embed_list = await self.extract_message_links(msg_after) - webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) - - if not webhook_embed_list: - await self.delete_msg_link_embed(msg_after.id) - return - - if webhook_msg_id: - await self.incidents_webhook.edit_message( - message_id=webhook_msg_id, - embeds=[embed for embed in webhook_embed_list if embed is not None], - ) - return - - await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) - @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: """ diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 06eafdde3..3bdc9128c 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -849,26 +849,3 @@ class TestMessageLinkEmbeds(TestIncidents): # Check for the embed descriptions for embed in embeds: self.assertEqual(embed.description, description) - - @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) - async def test_incident_message_edit(self): - """Edit the incident message and check whether `extract_message_links` is called or not.""" - self.cog_instance.incidents_webhook = MockAsyncWebhook(id=101) # Patch in our webhook - self.cog_instance.incidents_webhook.send = AsyncMock(return_value=MockMessage(id=191)) - - text_channel = MockTextChannel(id=123) - self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) - text_channel.fetch_message = AsyncMock(return_value=MockMessage(id=777, content="Did jason just screw up?")) - - payload = AsyncMock( - discord.RawMessageUpdateEvent, - channel_id=123, - message_id=456 - ) - - with patch( - "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() - ) as mock_extract_message_links: - await self.cog_instance.on_raw_message_edit(payload) - - mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From b8f2694b99e182f716cd3d241c34cfbfcc484954 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 11 Oct 2021 12:37:03 +0530 Subject: Apply requested grammar and style changes Co-authored-by: Bluenix Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 67 +++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 70f5272e0..0d28490b3 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -172,7 +172,6 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional try: message: discord.Message = await MessageConverter().convert(ctx, message_link) - except MessageNotFound: mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() @@ -197,10 +196,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional title="Bad Message Link", description=f"Message {message_link} not found." ) - except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") - else: channel = message.channel helpers_role = message.guild.get_role(Roles.helpers) @@ -292,13 +289,13 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - self.bot.loop.create_task(self.get_webhook()) + self.bot.loop.create_task(self.fetch_webhook()) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) - async def get_webhook(self) -> None: - """Fetch and store message link embeds webhook, present in #incidents channel.""" + async def fetch_webhook(self) -> None: + """Fetches the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) @@ -405,7 +402,7 @@ class Incidents(Cog): message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock forever should something go wrong. - Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the webhook message for that particular link from the channel. """ members_roles: t.Set[int] = {role.id for role in member.roles} @@ -546,12 +543,12 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is an incident. """ - if is_incident(message): - await add_signals(message) + if not is_incident(message): + return - webhook_embed_list = await self.extract_message_links(message) - if webhook_embed_list: - await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) + await add_signals(message) + if embed_list := await self.extract_message_links(message): + await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: @@ -564,31 +561,31 @@ class Incidents(Cog): async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: """ - Checks if there's any message links in the text content. + Check if there's any message links in the text content. - Then passes the the message_link into `make_message_link_embed` to format a + Then pass the message_link into `make_message_link_embed` to format an embed for it containing information about the link. - As discord only allows a max of 10 embeds in a single webhook, just send the + As Discord only allows a max of 10 embeds in a single webhook, just send the first 10 embeds and don't care about the rest. - If no links are found for the message, it logs a trace statement. + If no links are found for the message, just log a trace statement. """ message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + if not message_links: + log.trace( + f"No message links detected on incident message with id {message.id}." + ) + return - if message_links: - embeds = [] - for message_link in message_links[:10]: - ctx = await self.bot.get_context(message) - embed = await make_message_link_embed(ctx, message_link[0]) - if embed: - embeds.append(embed) - - return embeds + embeds = [] + for message_link in message_links[:10]: + ctx = await self.bot.get_context(message) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) - log.trace( - f"No message links detected on incident message with id {message.id}." - ) + return embeds async def send_message_link_embeds( self, @@ -597,12 +594,12 @@ class Incidents(Cog): webhook: discord.Webhook, ) -> t.Optional[int]: """ - Send Message Link Embeds to #incidents channel. + Send message link embeds to #incidents channel. - Uses the `webhook` passed in as a parameter to send + Using the `webhook` passed in as a parameter to send the embeds in the `webhook_embed_list` parameter. - After sending each webhook it maps the `message.id` + After sending each embed it maps the `message.id to the `webhook_msg_ids` IDs in the async redis-cache. """ try: @@ -612,20 +609,18 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - except discord.DiscordException: log.exception( f"Failed to send message link embed {message.id} to #incidents." ) - else: await self.message_link_embeds_cache.set(message.id, webhook_msg.id) - log.trace("Message Link Embed Sent successfully!") + log.trace("Message link embeds sent successfully to #incidents!") return webhook_msg.id async def delete_msg_link_embed(self, message_id: int) -> None: - """Delete discord message link message found in cache for `message`.""" - log.trace("Deleting discord links webhook message.") + """Delete the Discord message link message found in cache for `message_id`.""" + log.trace("Deleting Discord message link's webhook message.") webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id)) if webhook_msg_id: -- cgit v1.2.3 From 863c8d76c66ea748af9ab29bad1d02d16e3888f2 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 11 Oct 2021 12:52:39 +0530 Subject: Refactor `shorten_text` utility function --- bot/exts/moderation/incidents.py | 10 ++++++---- tests/bot/exts/moderation/test_incidents.py | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0d28490b3..4a84d825e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -142,16 +142,18 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) - lines = text.count("\n") + lines = text.split("\n") # Limit to a maximum of three lines - if lines > 3: - text = "\n".join(line for line in text.split('\n')[:3]) + if len(lines) > 3: + text = "\n".join(line for line in lines[:3]) + # If it is a single word, then truncate it to 50 characters if text.count(" ") < 1: text = text[:50] # Truncate text to a maximum of 300 characters - if len(text) > 300: + elif len(text) > 300: text = text[:300] + # Add placeholder if the text was shortened if len(text) < original_length: text += "..." diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3bdc9128c..8304af1c0 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -795,9 +795,16 @@ class TestMessageLinkEmbeds(TestIncidents): async def test_shorten_text(self): """Test all cases of text shortening by mocking messages.""" tests = { - "thisisasingleword"*10: ('thisisasingleword'*10)[:50]+"...", - "\n".join("Lets make a new line test".split()): "Lets\nmake\na"+"...", - 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' + "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...", + + "\n".join("Lets make a new line test".split()): "Lets\nmake\na...", + + 'Hello, World!' * 300: ( + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!H..." + ) } for content, expected_conversion in tests.items(): @@ -829,8 +836,10 @@ class TestMessageLinkEmbeds(TestIncidents): incident_msg = MockMessage( id=777, - content=f"I would like to report the following messages, " - f"as they break our rules: \n{', '.join(msg_links)}" + content=( + f"I would like to report the following messages, " + f"as they break our rules: \n{', '.join(msg_links)}" + ) ) with patch( -- cgit v1.2.3 From 7316463b54eeafef777468db872e81973663d435 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:36:04 +0530 Subject: Correct discord message link regex The biggest size a Discord snowflake can be is 20. Co-authored-by: Bluenix --- bot/exts/moderation/incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 4a84d825e..5d2c66d6c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,8 +26,8 @@ CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/" - r"[0-9]{15,21}" - r"\/[0-9]{15,21}\/[0-9]{15,21})" + r"[0-9]{15,20}" + r"\/[0-9]{15,20}\/[0-9]{15,20})" ) -- cgit v1.2.3 From 5e6e361ce5f08425d84e53fab611a3b8b685fcc0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:45:30 +0530 Subject: Add error handling on modlog channel fetch --- bot/exts/moderation/incidents.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5d2c66d6c..14bec3877 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -175,7 +175,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional try: message: discord.Message = await MessageConverter().convert(ctx, message_link) except MessageNotFound: - mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + try: + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + except discord.NotFound: + log.exception(f"Mod-logs (<#{Channels.mod_log}> channel not found.") + return + last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() for log_entry in last_100_logs: -- cgit v1.2.3 From f28fc6bdd57f6def0fcd8bf9e43af82e20f7fa1e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:47:50 +0530 Subject: Update typehints --- bot/exts/moderation/incidents.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 14bec3877..5c1554861 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,9 +1,9 @@ import asyncio import logging import re -import typing as t from datetime import datetime from enum import Enum +from typing import Optional import discord from async_rediscache import RedisCache @@ -45,17 +45,17 @@ class Signal(Enum): # Reactions from non-mod roles will be removed -ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) +ALLOWED_ROLES: set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check -ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +ALL_SIGNALS: set[str] = {signal.value for signal in Signal} # An embed coupled with an optional file to be dispatched # If the file is not None, the embed attempts to show it in its body -FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] +FileEmbed = tuple[discord.Embed, Optional[discord.File]] -async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: +async def download_file(attachment: discord.Attachment) -> Optional[discord.File]: """ Download & return `attachment` file. @@ -129,7 +129,7 @@ def is_incident(message: discord.Message) -> bool: return all(conditions) -def own_reactions(message: discord.Message) -> t.Set[str]: +def own_reactions(message: discord.Message) -> set[str]: """Get the set of reactions placed on `message` by the bot itself.""" return {str(reaction.emoji) for reaction in message.reactions if reaction.me} @@ -161,7 +161,7 @@ def shorten_text(text: str) -> str: return text -async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: +async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -412,7 +412,7 @@ class Incidents(Cog): Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the webhook message for that particular link from the channel. """ - members_roles: t.Set[int] = {role.id for role in member.roles} + members_roles: set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") try: @@ -462,7 +462,7 @@ class Incidents(Cog): # Deletes the message link embeds found in cache from the channel and cache. await self.delete_msg_link_embed(incident.id) - async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + async def resolve_message(self, message_id: int) -> Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -477,7 +477,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) + message: Optional[discord.Message] = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") @@ -566,7 +566,7 @@ class Incidents(Cog): """ await self.delete_msg_link_embed(payload.message_id) - async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: + async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]: """ Check if there's any message links in the text content. @@ -596,10 +596,10 @@ class Incidents(Cog): async def send_message_link_embeds( self, - webhook_embed_list: t.List, + webhook_embed_list: list, message: discord.Message, webhook: discord.Webhook, - ) -> t.Optional[int]: + ) -> Optional[int]: """ Send message link embeds to #incidents channel. -- cgit v1.2.3 From 9da71eddbf5641f2aa734a2fbe67fc444d390856 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:49:52 +0530 Subject: Use scheduling create_task util instead of creating from loop directly --- bot/exts/moderation/incidents.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5c1554861..65dc69ca6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -296,7 +296,7 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - self.bot.loop.create_task(self.fetch_webhook()) + scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) @@ -554,7 +554,9 @@ class Incidents(Cog): return await add_signals(message) - if embed_list := await self.extract_message_links(message): + + # Only use this feature if incidents webhook embed is found + if embed_list := await self.extract_message_links(message) and self.incidents_webhook: await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() -- cgit v1.2.3 From 50d1cd96623f4d6423326b23e590f9378e69c14c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 14:33:21 +0530 Subject: Check for webhook availability before extracting msg links --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 65dc69ca6..a02a38b24 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -556,8 +556,9 @@ class Incidents(Cog): await add_signals(message) # Only use this feature if incidents webhook embed is found - if embed_list := await self.extract_message_links(message) and self.incidents_webhook: - await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) + if self.incidents_webhook: + if embed_list := await self.extract_message_links(message): + await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: @@ -608,7 +609,7 @@ class Incidents(Cog): Using the `webhook` passed in as a parameter to send the embeds in the `webhook_embed_list` parameter. - After sending each embed it maps the `message.id + After sending each embed it maps the `message.id` to the `webhook_msg_ids` IDs in the async redis-cache. """ try: -- cgit v1.2.3 From 0324f5ba6a547242dde7543dacc60e069d767cec Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 14:38:11 +0530 Subject: Add incidents check in delete and reaction handlers also --- bot/exts/moderation/incidents.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a02a38b24..92b4fd5cf 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -459,8 +459,9 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") - # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embed(incident.id) + if self.incidents_webhook: + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embed(incident.id) async def resolve_message(self, message_id: int) -> Optional[discord.Message]: """ @@ -567,7 +568,8 @@ class Incidents(Cog): Search through the cache for message, if found delete it from cache and channel. """ - await self.delete_msg_link_embed(payload.message_id) + if self.incidents_webhook: + await self.delete_msg_link_embed(payload.message_id) async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]: """ -- cgit v1.2.3 From e84ced79ade1a28a7e24c307d16126dc80a17a7b Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 15:19:35 +0530 Subject: Refactor shorten_text utility function --- bot/exts/moderation/incidents.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5c5efdb15..b62ba0629 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -142,21 +142,23 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) - lines = text.split("\n") + # Truncate text to a maximum of 300 characters + if len(text) > 300: + text = text[:300] + # Limit to a maximum of three lines - if len(lines) > 3: - text = "\n".join(line for line in lines[:3]) + text = "\n".join(line for line in text.split("\n", maxsplit=3)[:3]) # If it is a single word, then truncate it to 50 characters - if text.count(" ") < 1: + if text.find(" ") == -1: text = text[:50] - # Truncate text to a maximum of 300 characters - elif len(text) > 300: - text = text[:300] + + # Remove extra whitespaces from the `text` + text = text.strip() # Add placeholder if the text was shortened if len(text) < original_length: - text += "..." + text = f"{text}..." return text -- cgit v1.2.3 From 67e304ec7eefad638fe264731f52e324bfd7fef0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 04:55:55 +0530 Subject: Removing config validation checks --- bot/exts/moderation/incidents.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b62ba0629..805b516c9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -177,11 +177,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d try: message: discord.Message = await MessageConverter().convert(ctx, message_link) except MessageNotFound: - try: - mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) - except discord.NotFound: - log.exception(f"Mod-logs (<#{Channels.mod_log}> channel not found.") - return + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() -- cgit v1.2.3 From 17e6cb4174c102a5e77aaa8515ba77634356b3e4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 04:58:34 +0530 Subject: Make docstring clear about max length --- bot/exts/moderation/incidents.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 805b516c9..9a526fa9f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -140,7 +140,11 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: - """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" + """ + Truncate the text if there are over 3 lines or 300 characters, or if it is a single word. + + The maximum length of the string would be 303 characters across 3 lines at maximum. + """ original_length = len(text) # Truncate text to a maximum of 300 characters if len(text) > 300: @@ -301,7 +305,7 @@ class Incidents(Cog): self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) async def fetch_webhook(self) -> None: - """Fetches the incidents webhook object, so we can post message link embeds to it.""" + """Fetch the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) -- cgit v1.2.3 From 792e05cf87cf52dde38031ca24de511623736c75 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 05:03:12 +0530 Subject: Add message creation timestamp to message link embed --- bot/exts/moderation/incidents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9a526fa9f..0b6b7ad9a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -222,7 +222,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - ) + ), + timestamp=message.created_at ) embed.add_field( name="Content", -- cgit v1.2.3 From 6402cc893833c2a03f8aca0048e674226e02cb72 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 05:04:48 +0530 Subject: Fix incident webhook fetch validation --- bot/exts/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0b6b7ad9a..693c01c81 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -308,9 +308,10 @@ class Incidents(Cog): async def fetch_webhook(self) -> None: """Fetch the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() - self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) - if not self.incidents_webhook: + try: + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + except discord.HTTPException: log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.") async def crawl_incidents(self) -> None: -- cgit v1.2.3 From 945313ea29bf053845665d829e675a1d78e2e545 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:24:57 +0530 Subject: avatar.url -> display_avatar.url --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 693c01c81..21aaafe4a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -623,7 +623,7 @@ class Incidents(Cog): webhook_msg = await webhook.send( embeds=[embed for embed in webhook_embed_list if embed], username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, + avatar_url=message.author.display_avatar.url, wait=True, ) except discord.DiscordException: -- cgit v1.2.3 From 14685ed77b6a359533ad2d72f11d26683fcc80e6 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:28:07 +0530 Subject: Fix helpers view perms check to use 'permissions_for' --- bot/exts/moderation/incidents.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 21aaafe4a..82add2579 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -209,11 +209,10 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: channel = message.channel - helpers_role = message.guild.get_role(Roles.helpers) - if not channel.overwrites_for(helpers_role).read_messages: + if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel: log.info( f"Helpers don't have read permissions in #{channel.name}," - " not sending message link embed for {message_link}" + f" not sending message link embed for {message_link}" ) return -- cgit v1.2.3 From 70948bc6ae3191030e3e173aacc1acd5890b3be7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:48:39 +0530 Subject: Missed a change for 43bed60 --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 82add2579..20e73ccf5 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -587,7 +587,7 @@ class Incidents(Cog): If no links are found for the message, just log a trace statement. """ - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) if not message_links: log.trace( f"No message links detected on incident message with id {message.id}." -- cgit v1.2.3 From f315f4c18571b3e5a7d8e322db4dbc0f3f13943b Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 31 Oct 2021 16:04:16 +0000 Subject: Add support for `!infractions by ` --- bot/exts/moderation/infraction/management.py | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b1c8b64dc..8c1ef057c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -262,6 +262,44 @@ class ModManagement(commands.Cog): ) await self.send_infraction_list(ctx, embed, infraction_list) + # endregion + # region: Search infractions by given user + @infraction_group.command(name="by", aliases=("b",)) + async def search_by_user( + self, + ctx: Context, + user: t.Union[discord.Member, t.Literal["m", "me"]], + oldest_first: bool = False + ) -> None: + """ + Search for infractions made by `user`. + + Use "m" or "me" as the `user` to get infractions by author. + + Use "1" for `oldest_first` to send oldest infractions first. + """ + if isinstance(user, discord.Member): + moderator_id = user.id + moderator_name_discrim = str(user) + else: + moderator_id = ctx.author.id + moderator_name_discrim = str(ctx.author) + + infraction_list = await self.bot.api_client.get( + 'bot/infractions/expanded', + params={ + 'actor__id': str(moderator_id), + 'ordering': f'{["-", ""][oldest_first]}inserted_at' # `'inserted_at'` makes api return oldest first + } + ) + + embed = discord.Embed( + title=f"Infractions by `{moderator_name_discrim}` (`{moderator_id}`)", + colour=discord.Colour.orange() + ) + + await self.send_infraction_list(ctx, embed, infraction_list) + # endregion # region: Utility functions -- cgit v1.2.3 From 06d0c8fc3674fa73e118b85b0d3b444f8d7a906f Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 2 Nov 2021 08:48:42 +0000 Subject: Address Review - Add `format_infraction_count` and migrate - Improve logic for `actor` being `"m"`/`"me"` - Rename `search_by_user` to `search_by_actor` - Better Ordering Logic (thanks @ChrisLovering) - Make embed title consistent with other search embeds --- bot/exts/moderation/infraction/management.py | 44 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 8c1ef057c..64913831a 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -243,8 +243,9 @@ class ModManagement(commands.Cog): else: user_str = str(user.id) + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions for {user_str} ({len(infraction_list)} total)", + title=f"Infractions for {user_str} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) @@ -256,45 +257,44 @@ class ModManagement(commands.Cog): 'bot/infractions/expanded', params={'search': reason} ) + + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + title=f"Infractions matching `{reason}` ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) # endregion - # region: Search infractions by given user + # region: Search for infractions by given actor @infraction_group.command(name="by", aliases=("b",)) - async def search_by_user( + async def search_by_actor( self, ctx: Context, - user: t.Union[discord.Member, t.Literal["m", "me"]], + actor: t.Union[discord.Member, t.Literal["m", "me"]], oldest_first: bool = False ) -> None: """ - Search for infractions made by `user`. + Search for infractions made by `actor`. - Use "m" or "me" as the `user` to get infractions by author. + Use "m" or "me" as the `actor` to get infractions by author. Use "1" for `oldest_first` to send oldest infractions first. """ - if isinstance(user, discord.Member): - moderator_id = user.id - moderator_name_discrim = str(user) - else: - moderator_id = ctx.author.id - moderator_name_discrim = str(ctx.author) + if isinstance(actor, str): + actor = ctx.author infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={ - 'actor__id': str(moderator_id), - 'ordering': f'{["-", ""][oldest_first]}inserted_at' # `'inserted_at'` makes api return oldest first + 'actor__id': str(actor.id), + 'ordering': f'{"-"[oldest_first:]}inserted_at' # `'inserted_at'` makes api return oldest first } ) + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions by `{moderator_name_discrim}` (`{moderator_id}`)", + title=f"Infractions by `{actor}` ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) @@ -303,6 +303,18 @@ class ModManagement(commands.Cog): # endregion # region: Utility functions + @staticmethod + def format_infraction_count(infraction_count: int) -> str: + """ + Returns a string-formatted infraction count. + + API limits returned infractions to a maximum of 100, so if `infraction_count` + is 100 then we return `"100+"`. Otherwise, return `str(infraction_count)`. + """ + if infraction_count == 100: + return "100+" + return str(infraction_count) + async def send_infraction_list( self, ctx: Context, -- cgit v1.2.3 From c6889dead07c0017864d0fb96028c864c2f752dc Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 3 Nov 2021 23:09:42 +0000 Subject: Improve ordering logic in API request --- bot/exts/moderation/infraction/management.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 64913831a..3299979e8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -284,11 +284,16 @@ class ModManagement(commands.Cog): if isinstance(actor, str): actor = ctx.author + if oldest_first: + ordering = 'inserted_at' # oldest infractions first + else: + ordering = '-inserted_at' # newest infractions first + infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={ 'actor__id': str(actor.id), - 'ordering': f'{"-"[oldest_first:]}inserted_at' # `'inserted_at'` makes api return oldest first + 'ordering': ordering } ) -- cgit v1.2.3 From 689850fd99737ab742dedf265cebbc19333535e1 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:39:04 +0530 Subject: Add no message content handling --- bot/exts/moderation/incidents.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index c531f4902..87a6579f7 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -224,12 +224,14 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d ), timestamp=message.created_at ) - embed.add_field( - name="Content", - value=shorten_text(message.content) - ) embed.set_footer(text=f"Message ID: {message.id}") + if message.content: + embed.add_field( + name="Content", + value=shorten_text(message.content) + ) + return embed -- cgit v1.2.3 From 19cb6d19134676b1eca797a9782010448d91eace Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:40:26 +0530 Subject: Attach attachments if present in linked message --- bot/exts/moderation/incidents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 87a6579f7..e7c9d0399 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -231,6 +231,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d name="Content", value=shorten_text(message.content) ) + if message.attachments: + embed.set_image(url=message.attachments[0].url) return embed -- cgit v1.2.3 From a3ee621fb53da75ba357a90c5b2d17529aef5c99 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:43:11 +0530 Subject: SHow thread parent name if linked message in thread --- bot/exts/moderation/incidents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e7c9d0399..18102587a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -220,7 +220,9 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" + f"**Channel:** {channel.mention} ({channel.category}" + f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}" + f"{channel.name})\n" ), timestamp=message.created_at ) -- cgit v1.2.3 From 32381cdfebd33b39bb69019f174b070beb32c44a Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:46:01 +0530 Subject: Explicitly show there is no message content --- bot/exts/moderation/incidents.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 18102587a..bdbce4acb 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -226,13 +226,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d ), timestamp=message.created_at ) + embed.add_field( + name="Content", + value=shorten_text(message.content) if message.content else "[No Message Content]" + ) embed.set_footer(text=f"Message ID: {message.id}") - if message.content: - embed.add_field( - name="Content", - value=shorten_text(message.content) - ) if message.attachments: embed.set_image(url=message.attachments[0].url) -- cgit v1.2.3 From 4b716c204147cfd1871db1b081e036cac172a9ad Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 8 Nov 2021 23:00:46 +0000 Subject: Add missing newline after region comment --- bot/exts/moderation/infraction/management.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 3299979e8..7314eb61d 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -267,6 +267,7 @@ class ModManagement(commands.Cog): # endregion # region: Search for infractions by given actor + @infraction_group.command(name="by", aliases=("b",)) async def search_by_actor( self, -- cgit v1.2.3 From 19df10cb968081442e9854ffffcdbc7c783dd0af Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 15:27:14 +0530 Subject: Filter the same messages in both listeners. --- bot/exts/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 462f8533d..1d8e571fb 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -613,7 +613,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if self.is_channel_ignored(event.channel_id): + if self.is_message_blacklisted(event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 2d2e7a31699a4eedd1f14aecf348368711b53536 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 16:14:59 +0530 Subject: Call the appropriate function in the raw listener --- bot/exts/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 1d8e571fb..462f8533d 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -613,7 +613,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if self.is_message_blacklisted(event.channel_id): + if self.is_channel_ignored(event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 78957a7ae4fbbe974b59f6a70fee5c0970378629 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 16:27:01 +0530 Subject: Listen to only on_raw_message_delete --- bot/exts/moderation/modlog.py | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 462f8533d..6416bc3c7 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -41,7 +41,6 @@ class ModLog(Cog, name="ModLog"): self.bot = bot self._ignored = {event: [] for event in Event} - self._cached_deletes = [] self._cached_edits = [] async def upload_log( @@ -552,24 +551,22 @@ class ModLog(Cog, name="ModLog"): return channel.id in GuildConstant.modlog_blacklist - @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: - """Log message delete event to message change log.""" + async def log_cached_deleted_message(self, message: discord.Message) -> None: + """ + Log the message's details to message change log. + + This is called when a cached message is deleted. + """ channel = message.channel author = message.author if self.is_message_blacklisted(message): return - self._cached_deletes.append(message.id) - if message.id in self._ignored[Event.message_delete]: self._ignored[Event.message_delete].remove(message.id) return - if author.bot: - return - if channel.category: response = ( f"**Author:** {format_user(author)}\n" @@ -610,17 +607,14 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.message_log ) - @Cog.listener() - async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: - """Log raw message delete event to message change log.""" - if self.is_channel_ignored(event.channel_id): - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired + async def log_uncached_deleted_message(self, event: discord.RawMessageDeleteEvent) -> None: + """ + Log the message's details to message change log. - if event.message_id in self._cached_deletes: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_deletes.remove(event.message_id) + This is called when a message absent from the cache is deleted. + Hence, the message contents aren't logged. + """ + if self.is_channel_ignored(event.channel_id): return if event.message_id in self._ignored[Event.message_delete]: @@ -651,6 +645,14 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.message_log ) + @Cog.listener() + async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: + """Log message deletions to message change log.""" + if event.cached_message is not None: + await self.log_cached_deleted_message(event.cached_message) + else: + await self.log_uncached_deleted_message(event) + @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" -- cgit v1.2.3 From cd3db5f9e51c79a9fe8f621cdafdc9052ef22033 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 10 Nov 2021 06:17:23 +0530 Subject: Check if log entry has embeds before indexing them --- bot/exts/moderation/incidents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index bdbce4acb..77dfad255 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -151,7 +151,7 @@ def shorten_text(text: str) -> str: text = text[:300] # Limit to a maximum of three lines - text = "\n".join(line for line in text.split("\n", maxsplit=3)[:3]) + text = "\n".join(text.split("\n", maxsplit=3)[:3]) # If it is a single word, then truncate it to 50 characters if text.find(" ") == -1: @@ -186,6 +186,9 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() for log_entry in last_100_logs: + if not log_entry.embeds: + continue + log_embed: discord.Embed = log_entry.embeds[0] if ( log_embed.author.name == "Message deleted" -- cgit v1.2.3 From 59c05a8494a5425545d38029b4b26890b6a631c1 Mon Sep 17 00:00:00 2001 From: aru Date: Wed, 10 Nov 2021 01:26:40 -0500 Subject: commands: add pip as an alias to pypi (#1942) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index c3d2e2a3c..dacf7bc12 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -29,7 +29,7 @@ class PyPi(Cog): def __init__(self, bot: Bot): self.bot = bot - @command(name="pypi", aliases=("package", "pack")) + @command(name="pypi", aliases=("package", "pack", "pip")) async def get_package_info(self, ctx: Context, package: str) -> None: """Provide information about a specific package from PyPI.""" embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red) -- cgit v1.2.3 From 2aad13887f049512ab8330f8aaa790086e3e6bea Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 10 Nov 2021 09:00:45 +0000 Subject: Unify infraction embed title Embed for `!infractions by` no longer has the author in codeblock. --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7314eb61d..192bb3ba9 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -300,7 +300,7 @@ class ModManagement(commands.Cog): formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions by `{actor}` ({formatted_infraction_count} total)", + title=f"Infractions by {actor} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) -- cgit v1.2.3 From 9c0d91b0bf5f8bf68a3fbadbf9da66726c355b81 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 10 Nov 2021 21:36:40 +0000 Subject: Merge PR #1947: Fix `!infractions by me` Put the literal converter before the Member converter so that "me"/"m" isn't attempted to be converted to a Member. --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 109b89a95..a833eb227 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -272,7 +272,7 @@ class ModManagement(commands.Cog): async def search_by_actor( self, ctx: Context, - actor: t.Union[discord.Member, t.Literal["m", "me"]], + actor: t.Union[t.Literal["m", "me"], UnambiguousUser], oldest_first: bool = False ) -> None: """ -- cgit v1.2.3 From a345a912a88a53123bdd5d0806530c62d5166a9a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 12 Nov 2021 00:08:19 +0000 Subject: Change log level from `WARNING` to `DEBUG`. (#1950) --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0905cb23d..944a99917 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -128,9 +128,7 @@ class HelpChannels(commands.Cog): # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) if not isinstance(message.author, discord.Member): - log.warning( - f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM." - ) + log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") else: await self._handle_role_change(message.author, message.author.add_roles) -- cgit v1.2.3 From b1f73f4c1a67f061ee2c1cd74980fcd725ae9f1e Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 14 Nov 2021 03:30:10 +0000 Subject: Add #bot-commands to guild features in !server This prevents spam in dev-contrib and dev-core from people trying to find which Discord feature flags are enabled for Python Discord. It's not ideal that we have to increase output size in #bot-commands but it prevents spam in #dev-contrib. --- bot/exts/info/information.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 7f4811a43..dab2dbb6c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -178,7 +178,10 @@ class Information(Cog): # Server Features are only useful in certain channels if ctx.channel.id in ( - *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib + *constants.MODERATION_CHANNELS, + constants.Channels.dev_core, + constants.Channels.dev_contrib, + constants.Channels.bot_commands ): features = f"\nFeatures: {', '.join(ctx.guild.features)}" else: -- cgit v1.2.3 From df8a44957aca30f929ed1bdebc97ff33ab5af1ba Mon Sep 17 00:00:00 2001 From: mina <75038675+minalike@users.noreply.github.com> Date: Sun, 14 Nov 2021 16:41:46 -0500 Subject: Update order of off-topic channels (#1956) Reverse order of off-topic channels from ot0, ot1, ot2 to ot2, ot1, ot0 --- bot/resources/tags/off-topic.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 6a864a1d5..287224d7f 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,9 +1,9 @@ **Off-topic channels** There are three off-topic channels: -â€ĸ <#291284109232308226> -â€ĸ <#463035241142026251> â€ĸ <#463035268514185226> +â€ĸ <#463035241142026251> +â€ĸ <#291284109232308226> Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. -- cgit v1.2.3 From 6cebf897f0f1fa767bb593cfb7208a3d8b3a43c5 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 16 Nov 2021 10:13:06 +0000 Subject: Add ability to reply to message for `!remind` --- bot/exts/utils/reminders.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 86e4505fa..90677b2dd 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -214,7 +214,7 @@ class Reminders(Cog): @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None ) -> None: """ Commands for managing your reminders. @@ -234,7 +234,7 @@ class Reminders(Cog): @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None ) -> None: """ Set yourself a simple reminder. @@ -283,6 +283,20 @@ class Reminders(Cog): 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', -- cgit v1.2.3 From 9cdfe35abcc2379db8b4d98d9c74f3c60e230984 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Tue, 16 Nov 2021 14:08:34 +0000 Subject: Don't log threads in admin channels (#1954) This change disables the mod-log for any changes to threads in channels that mods don't have read perms to. Co-authored-by: Kieran Siek --- bot/exts/moderation/modlog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 6416bc3c7..91709e5e5 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -775,6 +775,10 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_thread_update(self, before: Thread, after: Thread) -> None: """Log thread archiving, un-archiving and name edits.""" + if self.is_channel_ignored(after.id): + log.trace("Ignoring update of thread %s (%d)", after.mention, after.id) + return + if before.name != after.name: await self.send_log_message( Icons.hash_blurple, @@ -811,6 +815,10 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_thread_delete(self, thread: Thread) -> None: """Log thread deletion.""" + if self.is_channel_ignored(thread.id): + log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id) + return + await self.send_log_message( Icons.hash_red, Colours.soft_red, @@ -829,6 +837,10 @@ class ModLog(Cog, name="ModLog"): if thread.me: return + if self.is_channel_ignored(thread.id): + log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id) + return + await self.send_log_message( Icons.hash_green, Colours.soft_green, -- cgit v1.2.3 From b55a2b94ea666f6891bcbc1c4d0e67857b1900ef Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 19 Nov 2021 20:00:44 +0000 Subject: Remove unneeded new lines These new lines made the output embed look far to spaced out. --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index c0ef80e3d..bb3cc5380 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -30,9 +30,9 @@ Infraction = t.Dict[str, t.Union[str, int, bool]] APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm" INFRACTION_TITLE = "Please review our rules" -INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." +INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." INFRACTION_APPEAL_MODMAIL_FOOTER = ( - '\n\nIf you would like to discuss or appeal this infraction, ' + '\nIf you would like to discuss or appeal this infraction, ' 'send a message to the ModMail bot.' ) INFRACTION_AUTHOR_NAME = "Infraction information" -- cgit v1.2.3 From d14a15886301ba660564bfd80480d46d5a435e65 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 20 Nov 2021 23:04:01 +0300 Subject: Disable File Logging By Default Place logging to file behind an environment variable, and remove special considerations made for it. Signed-off-by: Hassan Abouelela --- bot/constants.py | 1 + bot/log.py | 15 ++++++++------- config-default.yml | 3 ++- docker-compose.yml | 1 - 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 2dfdd51e2..36b917734 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -685,6 +685,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" +FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/bot/log.py b/bot/log.py index b3cecdcf2..100cd06f6 100644 --- a/bot/log.py +++ b/bot/log.py @@ -48,16 +48,17 @@ def setup() -> None: logging.addLevelName(TRACE_LEVEL, "TRACE") logging.setLoggerClass(CustomLogger) + root_log = get_logger() + format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" log_format = logging.Formatter(format_string) - log_file = Path("logs", "bot.log") - log_file.parent.mkdir(exist_ok=True) - file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") - file_handler.setFormatter(log_format) - - root_log = get_logger() - root_log.addHandler(file_handler) + if constants.FILE_LOGS: + log_file = Path("logs", "bot.log") + log_file.parent.mkdir(exist_ok=True) + file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") + file_handler.setFormatter(log_format) + root_log.addHandler(file_handler) if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: coloredlogs.DEFAULT_LEVEL_STYLES = { diff --git a/config-default.yml b/config-default.yml index ed3c3a638..7400cf200 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,4 +1,5 @@ -debug: !ENV ["BOT_DEBUG", "true"] +debug: !ENV ["BOT_DEBUG", "true"] +file_logs: !ENV ["FILE_LOGS", "false"] bot: diff --git a/docker-compose.yml b/docker-compose.yml index b3ca6baa4..869d9acb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,7 +90,6 @@ services: context: . dockerfile: Dockerfile volumes: - - ./logs:/bot/logs - .:/bot:ro tty: true depends_on: -- cgit v1.2.3 From 1872bb12766bf3c56284ef614b5ab22166b488e5 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 22 Nov 2021 18:14:43 +0000 Subject: Deal with activity_blocks not being returned by site We are planning to change metricity endpoints on site so that activcity_blocks are not returned if the user has more than 1000 messages. This is because the query to calculate those blocks can get expensive at a high message count. To deal with this, both places activity_blocks are used has been changed to reflect this planned behaviour. --- bot/exts/info/information.py | 7 ++++++- bot/exts/moderation/voice_gate.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index dab2dbb6c..5b48495dc 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -422,7 +422,12 @@ class Information(Cog): activity_output = "No activity" else: activity_output.append(user_activity["total_messages"] or "No messages") - activity_output.append(user_activity["activity_blocks"] or "No activity") + + if (activity_blocks := user_activity.get("activity_blocks")) is not None: + # activity_blocks is not included in the response if the user has a lot of messages + activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0. + else: + activity_output.append("Too many to count!") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 31799ec73..ae55a03a0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -171,8 +171,12 @@ class VoiceGate(Cog): ), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], - "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks } + if activity_blocks := data.get("activity_blocks"): + # activity_blocks is not included in the response if the user has a lot of messages. + # Only check if the user has enough activity blocks if it is included. + checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks + failed = any(checks.values()) failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] -- cgit v1.2.3 From 2024766f182b1491e8d2031e3e26d0830563e578 Mon Sep 17 00:00:00 2001 From: zephyrus <75779179+git-zephyrus@users.noreply.github.com> Date: Wed, 24 Nov 2021 21:57:48 +0530 Subject: Suppress NotFound error when cleaning messages * Added suppress for notfound error * Update clean.py --- bot/exts/moderation/clean.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 94494b983..826265aa3 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -293,7 +293,8 @@ class Clean(Cog): return deleted if len(to_delete) > 0: # Deleting any leftover messages if there are any - await channel.delete_messages(to_delete) + with suppress(NotFound): + await channel.delete_messages(to_delete) deleted.extend(to_delete) if not self.cleaning: -- cgit v1.2.3 From 68f27b730c27d58805556291f833030d37446425 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 15:57:49 -0700 Subject: Limit length of the invalid rule indices message --- bot/exts/info/site.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index e8e71558b..3b4b561c7 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,3 +1,5 @@ +from textwrap import shorten + from discord import Colour, Embed from discord.ext.commands import Cog, Context, Greedy, group @@ -123,7 +125,7 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) + invalid = shorten(', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)), 50) if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From 94944ca0bd3dae1c97483f93563846eea7380dfb Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:10:40 -0700 Subject: Change placeholder for invalid rules message shortening ... is used everywhere else across the codebase where extwrap.shorten is used, so I'm making it match here. --- bot/exts/info/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 3b4b561c7..bcb04c909 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -125,7 +125,8 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = shorten(', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)), 50) + invalid = shorten(', '.join(str(index) for index in rules if index + < 1 or index > len(full_rules)), 50, placeholder='...') if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From 7e8ecb4f2acc7e1e88d4c053091926c07965293d Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:18:26 -0700 Subject: Add missing space in text shortening placeholder --- bot/exts/info/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index bcb04c909..c622441bd 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -126,7 +126,7 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) invalid = shorten(', '.join(str(index) for index in rules if index - < 1 or index > len(full_rules)), 50, placeholder='...') + < 1 or index > len(full_rules)), 50, placeholder=' ...') if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From d870f28027c708fef3f0e1cc035196e727485cce Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:33:42 -0700 Subject: Refactor long line Doing this similar to how the docs command works for shortening --- bot/exts/info/site.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index c622441bd..f6499ecce 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -125,11 +125,11 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = shorten(', '.join(str(index) for index in rules if index - < 1 or index > len(full_rules)), 50, placeholder=' ...') + + invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) if invalid: - await ctx.send(f":x: Invalid rule indices: {invalid}") + await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=' ...')) return for rule in rules: -- cgit v1.2.3 From b57af0e076ef3e7eb1f7035429e56664ddf1ed55 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sun, 28 Nov 2021 01:47:05 -0700 Subject: Use bright_green for "Currently Helping" DMs (#1979) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 944a99917..0c411df04 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -590,7 +590,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", description=f"You're currently helping in {message.channel.mention}", - color=constants.Colours.soft_green, + color=constants.Colours.bright_green, timestamp=message.created_at ) embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") -- cgit v1.2.3 From 8680df24222dc4b4828cd2df78f8f2b44d0b1e27 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:30:40 +0100 Subject: Move handle_role_change to a util file --- bot/exts/help_channels/_cog.py | 30 +++++++++--------------------- bot/utils/members.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0c411df04..60209ba6e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -66,6 +66,9 @@ class HelpChannels(commands.Cog): self.bot = bot self.scheduler = scheduling.Scheduler(self.__class__.__name__) + self.guild: discord.Guild = None + self.cooldown_role: discord.Role = None + # Categories self.available_category: discord.CategoryChannel = None self.in_use_category: discord.CategoryChannel = None @@ -95,24 +98,6 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() - async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None: - """ - Change `member`'s cooldown role via awaiting `coro` and handle errors. - - `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - try: - await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown)) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) @@ -130,7 +115,7 @@ class HelpChannels(commands.Cog): if not isinstance(message.author, discord.Member): log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") else: - await self._handle_role_change(message.author, message.author.add_roles) + await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role) try: await _message.dm_on_open(message) @@ -302,6 +287,9 @@ class HelpChannels(commands.Cog): await self.bot.wait_until_guild_available() log.trace("Initialising the cog.") + self.guild = self.bot.get_guild(constants.Guild.id) + self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown) + await self.init_categories() self.channel_queue = self.create_channel_queue() @@ -445,11 +433,11 @@ class HelpChannels(commands.Cog): await _caches.claimants.delete(channel.id) await _caches.session_participants.delete(channel.id) - claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id) + claimant = await members.get_or_fetch_member(self.guild, claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") else: - await self._handle_role_change(claimant, claimant.remove_roles) + await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) diff --git a/bot/utils/members.py b/bot/utils/members.py index 77ddf1696..693286045 100644 --- a/bot/utils/members.py +++ b/bot/utils/members.py @@ -23,3 +23,26 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optiona return None log.trace("%s fetched from API.", member) return member + + +async def handle_role_change( + member: discord.Member, + coro: t.Callable[..., t.Coroutine], + role: discord.Role +) -> None: + """ + Change `member`'s cooldown role via awaiting `coro` and handle errors. + + `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + try: + await coro(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") -- cgit v1.2.3 From 0465db98be1d739eea69e8a2f7cf4b939c65c96d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:31:25 +0100 Subject: Remove the subscribe command from the verification cog --- bot/exts/moderation/verification.py | 71 +++---------------------------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ed5571d2a..37338d19c 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -5,9 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role from bot import constants from bot.bot import Bot -from bot.decorators import in_whitelist from bot.log import get_logger -from bot.utils.checks import InWhitelistCheckFailure log = get_logger(__name__) @@ -29,11 +27,11 @@ You can find a copy of our rules for reference at -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \ to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. +If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \ +<#{constants.Channels.bot_commands}> and click the role again!. To introduce you to our community, we've made the following video: https://youtu.be/ZH26PuX3re0 @@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None: class Verification(Cog): """ - User verification and role management. + User verification. Statistics are collected in the 'verification.' namespace. - - Additionally, this cog offers the !subscribe and !unsubscribe commands, """ def __init__(self, bot: Bot) -> None: @@ -107,68 +103,9 @@ class Verification(Cog): except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - # endregion - # region: subscribe commands - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles( - discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" - ) - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - # endregion # region: miscellaneous - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - @command(name='verify') @has_any_role(*constants.MODERATION_ROLES) async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: -- cgit v1.2.3 From 5df26bafa58ed036333eb1d4fa7438cf93c4b7c9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:38:13 +0100 Subject: Add self assignable roles to config --- bot/constants.py | 5 +++++ config-default.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 36b917734..36a92da1f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -484,7 +484,12 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" + # Self-assignable roles, see the Subscribe cog + advent_of_code: int announcements: int + lovefest: int + pyweek_announcements: int + contributors: int help_cooldown: int muted: int diff --git a/config-default.yml b/config-default.yml index 7400cf200..0d3ddc005 100644 --- a/config-default.yml +++ b/config-default.yml @@ -264,7 +264,12 @@ guild: - *BLACK_FORMATTER roles: + # Self-assignable roles, see the Subscribe cog + advent_of_code: 518565788744024082 announcements: 463658397560995840 + lovefest: 542431903886606399 + pyweek_announcements: 897568414044938310 + contributors: 295488872404484098 help_cooldown: 699189276025421825 muted: &MUTED_ROLE 277914926603829249 -- cgit v1.2.3 From 4f7010912ccc75ea1415bc5e1e10fbce17c43b69 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:38:54 +0100 Subject: Add an interactive subscribe command This command gives the users a set of buttons to click to add or remove pre-determined announcement roles. Adding or removing a role updates the button state to reflect the change and what would happen if the user clicks the button again. --- bot/exts/info/subscribe.py | 139 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 bot/exts/info/subscribe.py diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py new file mode 100644 index 000000000..edf8e8f9e --- /dev/null +++ b/bot/exts/info/subscribe.py @@ -0,0 +1,139 @@ +import logging + +import arrow +import discord +from discord.ext import commands +from discord.interactions import Interaction + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist +from bot.utils import checks, members, scheduling + +# Tuple of tuples, where each inner tuple is a role id and a month number. +# The month number signifies what month the role should be assignable, +# use None for the month number if it should always be active. +ASSIGNABLE_ROLES = ( + (constants.Roles.announcements, None), + (constants.Roles.pyweek_announcements, None), + (constants.Roles.lovefest, 2), + (constants.Roles.advent_of_code, 12), +) +ITEMS_PER_ROW = 3 + +log = logging.getLogger(__name__) + + +class RoleButtonView(discord.ui.View): + """A list of SingleRoleButtons to show to the member.""" + + def __init__(self, member: discord.Member): + super().__init__() + self.interaction_owner = member + + async def interaction_check(self, interaction: Interaction) -> bool: + """Ensure that the user clicking the button is the member who invoked the command.""" + if interaction.user != self.interaction_owner: + await interaction.response.send_message( + ":x: This is not your command to react to!", + ephemeral=True + ) + return False + return True + + +class SingleRoleButton(discord.ui.Button): + """A button that adds or removes a role from the member depending on it's current state.""" + + ADD_STYLE = discord.ButtonStyle.success + REMOVE_STYLE = discord.ButtonStyle.secondary + LABEL_FORMAT = "{action} role {role_name}" + CUSTOM_ID_FORMAT = "subscribe-{role_id}" + + def __init__(self, role: discord.Role, assigned: bool, row: int): + super().__init__( + style=self.REMOVE_STYLE if assigned else self.ADD_STYLE, + label=self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name), + custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.id), + row=row, + ) + self.role = role + self.assigned = assigned + + async def callback(self, interaction: Interaction) -> None: + """Update the member's role and change button text to reflect current text.""" + await members.handle_role_change( + interaction.user, + interaction.user.remove_roles if self.assigned else interaction.user.add_roles, + self.role, + ) + + self.assigned = not self.assigned + await self.update_view(interaction) + await interaction.response.send_message( + self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name), + ephemeral=True, + ) + + async def update_view(self, interaction: Interaction) -> None: + """Updates the original interaction message with a new view object with the updated buttons.""" + self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE + self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name) + try: + await interaction.message.edit(view=self.view) + except discord.NotFound: + log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user) + self.view.stop() + + +class Subscribe(commands.Cog): + """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) + self.assignable_roles: list[discord.Role] = [] + self.guild: discord.Guild = None + + async def init_cog(self) -> None: + """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" + await self.bot.wait_until_guild_available() + + current_month = arrow.utcnow().month + self.guild = self.bot.get_guild(constants.Guild.id) + + for role_id, month_available in ASSIGNABLE_ROLES: + if month_available is not None and month_available != current_month: + continue + role = self.guild.get_role(role_id) + if role is None: + log.warning("Could not resolve %d to a role in the guild, skipping.", role_id) + continue + self.assignable_roles.append(role) + + @commands.command(name="subscribe") + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args + """Display the member's current state for each role, and allow them to add/remove the roles.""" + await self.init_task + + button_view = RoleButtonView(ctx.author) + for index, role in enumerate(self.assignable_roles): + row = index // ITEMS_PER_ROW + button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) + + await ctx.send("Click the buttons below to add or remove your roles!", view=button_view) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, checks.InWhitelistCheckFailure): + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Subscribe cog.""" + if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons. + log.error("Too many roles for 5 rows, not loading the Subscribe cog.") + else: + bot.add_cog(Subscribe(bot)) -- cgit v1.2.3 From 4c982870749f3545c971c20eb19a3c5eafe67668 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 09:34:07 +0100 Subject: Ensure the user interacting is still in guild before changing roles --- bot/exts/info/subscribe.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index edf8e8f9e..bf3120a3a 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -62,6 +62,10 @@ class SingleRoleButton(discord.ui.Button): async def callback(self, interaction: Interaction) -> None: """Update the member's role and change button text to reflect current text.""" + if isinstance(interaction.user, discord.User): + log.trace("User %s is not a member", interaction.user) + await interaction.message.delete() + return await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, -- cgit v1.2.3 From 1d7765c5629efaccdd4741b8fd6640f7fd6dab09 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 17:49:51 +0100 Subject: Add 10s member cooldown to subscribe command --- bot/exts/info/subscribe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index bf3120a3a..121fa3685 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -115,6 +115,7 @@ class Subscribe(commands.Cog): continue self.assignable_roles.append(role) + @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") @in_whitelist(channels=(constants.Channels.bot_commands,)) async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args -- cgit v1.2.3 From 9a3be9ee23df63792d942950ccb378750ddc3ac7 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 17:51:28 +0100 Subject: Stop listening for events when message is deleted --- bot/exts/info/subscribe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 121fa3685..5dad013d1 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -65,7 +65,9 @@ class SingleRoleButton(discord.ui.Button): if isinstance(interaction.user, discord.User): log.trace("User %s is not a member", interaction.user) await interaction.message.delete() + self.view.stop() return + await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, -- cgit v1.2.3 From b748d1310b2c731ac46e0bbc864d4d28a5439b37 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 17 Oct 2021 09:34:18 +0100 Subject: Use new get_logger helper util --- bot/exts/info/subscribe.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 5dad013d1..a2a0de976 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,5 +1,3 @@ -import logging - import arrow import discord from discord.ext import commands @@ -8,6 +6,7 @@ from discord.interactions import Interaction from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist +from bot.log import get_logger from bot.utils import checks, members, scheduling # Tuple of tuples, where each inner tuple is a role id and a month number. @@ -21,7 +20,7 @@ ASSIGNABLE_ROLES = ( ) ITEMS_PER_ROW = 3 -log = logging.getLogger(__name__) +log = get_logger(__name__) class RoleButtonView(discord.ui.View): -- cgit v1.2.3 From 8b109837e0cba62574ef4269e512d3fe23f6b37e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 10:30:31 +0000 Subject: Delete the subscribe message after 5 minutes --- bot/exts/info/subscribe.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index a2a0de976..17bb24dca 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -19,6 +19,7 @@ ASSIGNABLE_ROLES = ( (constants.Roles.advent_of_code, 12), ) ITEMS_PER_ROW = 3 +DELETE_MESSAGE_AFTER = 300 # Seconds log = get_logger(__name__) @@ -128,7 +129,11 @@ class Subscribe(commands.Cog): row = index // ITEMS_PER_ROW button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) - await ctx.send("Click the buttons below to add or remove your roles!", view=button_view) + await ctx.send( + "Click the buttons below to add or remove your roles!", + view=button_view, + delete_after=DELETE_MESSAGE_AFTER, + ) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: -- cgit v1.2.3 From 7f22abfd3ec443cf0925f2c6e609be681c723799 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 11:31:30 +0000 Subject: Allow roles to be assignable over multiple months This includes a refactor to use a dataclass for clearer implementation. Along with that, this changes the roles so that they're always available, but un-assignable roles are in red and give a different error. --- bot/exts/info/subscribe.py | 95 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 17bb24dca..9b96e7ab2 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,3 +1,7 @@ +import calendar +import typing as t +from dataclasses import dataclass + import arrow import discord from discord.ext import commands @@ -9,15 +13,44 @@ from bot.decorators import in_whitelist from bot.log import get_logger from bot.utils import checks, members, scheduling -# Tuple of tuples, where each inner tuple is a role id and a month number. -# The month number signifies what month the role should be assignable, -# use None for the month number if it should always be active. + +@dataclass(frozen=True) +class AssignableRole: + """ + A role that can be assigned to a user. + + months_available is a tuple that signifies what months the role should be + self-assignable, using None for when it should always be available. + """ + + role_id: int + months_available: t.Optional[tuple[int]] + name: t.Optional[str] = None # This gets populated within Subscribe.init_cog() + + def is_currently_available(self) -> bool: + """Check if the role is available for the current month.""" + if self.months_available is None: + return True + return arrow.utcnow().month in self.months_available + + def get_readable_available_months(self) -> str: + """Get a readable string of the months the role is available.""" + if self.months_available is None: + return f"{self.name} is always available." + + # Join the months together with comma separators, but use "and" for the final seperator. + month_names = [calendar.month_name[month] for month in self.months_available] + available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}" + return f"{self.name} can only be assigned during {available_months_str}." + + ASSIGNABLE_ROLES = ( - (constants.Roles.announcements, None), - (constants.Roles.pyweek_announcements, None), - (constants.Roles.lovefest, 2), - (constants.Roles.advent_of_code, 12), + AssignableRole(constants.Roles.announcements, None), + AssignableRole(constants.Roles.pyweek_announcements, None), + AssignableRole(constants.Roles.lovefest, (1, 2)), + AssignableRole(constants.Roles.advent_of_code, (11, 12)), ) + ITEMS_PER_ROW = 3 DELETE_MESSAGE_AFTER = 300 # Seconds @@ -47,14 +80,22 @@ class SingleRoleButton(discord.ui.Button): ADD_STYLE = discord.ButtonStyle.success REMOVE_STYLE = discord.ButtonStyle.secondary - LABEL_FORMAT = "{action} role {role_name}" + UNAVAILABLE_STYLE = discord.ButtonStyle.red + LABEL_FORMAT = "{action} role {role_name}." CUSTOM_ID_FORMAT = "subscribe-{role_id}" - def __init__(self, role: discord.Role, assigned: bool, row: int): + def __init__(self, role: AssignableRole, assigned: bool, row: int): + if role.is_currently_available(): + style = self.REMOVE_STYLE if assigned else self.ADD_STYLE + label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) + else: + style = self.UNAVAILABLE_STYLE + label = role.name + super().__init__( - style=self.REMOVE_STYLE if assigned else self.ADD_STYLE, - label=self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name), - custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.id), + style=style, + label=label, + custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id), row=row, ) self.role = role @@ -68,10 +109,14 @@ class SingleRoleButton(discord.ui.Button): self.view.stop() return + if not self.role.is_currently_available(): + await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True) + return + await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, - self.role, + discord.Object(self.role.role_id), ) self.assigned = not self.assigned @@ -98,24 +143,27 @@ class Subscribe(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) - self.assignable_roles: list[discord.Role] = [] + self.assignable_roles: list[AssignableRole] = [] self.guild: discord.Guild = None async def init_cog(self) -> None: """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" await self.bot.wait_until_guild_available() - current_month = arrow.utcnow().month self.guild = self.bot.get_guild(constants.Guild.id) - for role_id, month_available in ASSIGNABLE_ROLES: - if month_available is not None and month_available != current_month: - continue - role = self.guild.get_role(role_id) - if role is None: - log.warning("Could not resolve %d to a role in the guild, skipping.", role_id) + for role in ASSIGNABLE_ROLES: + discord_role = self.guild.get_role(role.role_id) + if discord_role is None: + log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id) continue - self.assignable_roles.append(role) + self.assignable_roles.append( + AssignableRole( + role_id=role.role_id, + months_available=role.months_available, + name=discord_role.name, + ) + ) @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") @@ -125,9 +173,10 @@ class Subscribe(commands.Cog): await self.init_task button_view = RoleButtonView(ctx.author) + author_roles = [role.id for role in ctx.author.roles] for index, role in enumerate(self.assignable_roles): row = index // ITEMS_PER_ROW - button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) + button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) await ctx.send( "Click the buttons below to add or remove your roles!", -- cgit v1.2.3 From 19eef3ed7135572ad52bbf145278efcdd142b0c0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 11:36:15 +0000 Subject: Sort unavailable self-assignable roles to the end of the list --- bot/exts/info/subscribe.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 9b96e7ab2..d24e8716e 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,4 +1,5 @@ import calendar +import operator import typing as t from dataclasses import dataclass @@ -164,6 +165,8 @@ class Subscribe(commands.Cog): name=discord_role.name, ) ) + # Sort unavailable roles to the end of the list + self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True) @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") -- cgit v1.2.3 From 005af3bc34310d9374bfd1deeaf37da080c7fee1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:06:28 +0000 Subject: Swap remove and unavailable colours for subscribe command --- bot/exts/info/subscribe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index d24e8716e..d097e6290 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -80,8 +80,8 @@ class SingleRoleButton(discord.ui.Button): """A button that adds or removes a role from the member depending on it's current state.""" ADD_STYLE = discord.ButtonStyle.success - REMOVE_STYLE = discord.ButtonStyle.secondary - UNAVAILABLE_STYLE = discord.ButtonStyle.red + REMOVE_STYLE = discord.ButtonStyle.red + UNAVAILABLE_STYLE = discord.ButtonStyle.secondary LABEL_FORMAT = "{action} role {role_name}." CUSTOM_ID_FORMAT = "subscribe-{role_id}" -- cgit v1.2.3 From 57c1b8e6bbadf8139597e7105d6681a13781b69a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:06:53 +0000 Subject: Add lock emoji to highlight unavailable self-assignable roles --- bot/exts/info/subscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index d097e6290..16379d2b2 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -91,7 +91,7 @@ class SingleRoleButton(discord.ui.Button): label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) else: style = self.UNAVAILABLE_STYLE - label = role.name + label = f"🔒 {role.name}" super().__init__( style=style, -- cgit v1.2.3 From aecb093afc95d28b85a63714cde9ae33e9068ae8 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:07:16 +0000 Subject: Subscribe command replies to invocation to keep context --- bot/exts/info/subscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 16379d2b2..2e6101d27 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -181,7 +181,7 @@ class Subscribe(commands.Cog): row = index // ITEMS_PER_ROW button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) - await ctx.send( + await ctx.reply( "Click the buttons below to add or remove your roles!", view=button_view, delete_after=DELETE_MESSAGE_AFTER, -- cgit v1.2.3 From edb18d5f5be3d1dfcfdcfa72bcbf0915e321b895 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:16:05 +0000 Subject: Add thread archive time enum to constants --- bot/constants.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 36b917734..93da6a906 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -683,10 +683,22 @@ class VideoPermission(metaclass=YAMLGetter): default_permission_duration: int +class ThreadArchiveTimes(Enum): + HOUR = 60 + DAY = 1440 + THREE_DAY = 4230 + WEEK = 10080 + + # Debug mode DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" +if DEBUG_MODE: + DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.HOUR.value +else: + DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.WEEK.value + # Paths BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) -- cgit v1.2.3 From 292a500d9ebb51b8efc023baf39b76d98d05cae0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:19:36 +0000 Subject: Refactor make_review to return nominee too --- bot/exts/recruitment/talentpool/_cog.py | 2 +- bot/exts/recruitment/talentpool/_review.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 2fafaec97..699d60f42 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -483,7 +483,7 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" - review = (await self.reviewer.make_review(user_id))[0] + review, _, _ = await self.reviewer.make_review(user_id) if review: file = discord.File(StringIO(review), f"{user_id}_review.md") await ctx.send(file=file) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index d880c524c..6b5fae3b1 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -78,14 +78,14 @@ class Reviewer: async def post_review(self, user_id: int, update_database: bool) -> None: """Format the review of a user and post it to the nomination voting channel.""" - review, reviewed_emoji = await self.make_review(user_id) + review, reviewed_emoji, nominee = await self.make_review(user_id) if not review: return guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.nomination_voting) - log.trace(f"Posting the review of {user_id}") + log.trace(f"Posting the review of {nominee} ({nominee.id})") messages = await self._bulk_send(channel, review) await pin_no_system_message(messages[0]) @@ -113,14 +113,14 @@ class Reviewer: return "", None guild = self.bot.get_guild(Guild.id) - member = await get_or_fetch_member(guild, user_id) + nominee = await get_or_fetch_member(guild, user_id) - if not member: + if not nominee: return ( f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" - ), None + ), None, None - opening = f"{member.mention} ({member}) for Helper!" + opening = f"{nominee.mention} ({nominee}) for Helper!" current_nominations = "\n\n".join( f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" @@ -128,7 +128,7 @@ class Reviewer: ) current_nominations = f"**Nominated by:**\n{current_nominations}" - review_body = await self._construct_review_body(member) + review_body = await self._construct_review_body(nominee) reviewed_emoji = self._random_ducky(guild) vote_request = ( @@ -138,7 +138,7 @@ class Reviewer: ) review = "\n\n".join((opening, current_nominations, review_body, vote_request)) - return review, reviewed_emoji + return review, reviewed_emoji, nominee async def archive_vote(self, message: PartialMessage, passed: bool) -> None: """Archive this vote to #nomination-archive.""" -- cgit v1.2.3 From c217c3ef658954f2d491529a2a5c2085a285c229 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:20:42 +0000 Subject: Manage nomination threads This change creates a thread while posting the nomination, and then archives it once the nomination is concluded. --- bot/exts/recruitment/talentpool/_review.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 6b5fae3b1..bc5cccda1 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild +from bot.constants import Channels, Colours, DEFAULT_THREAD_ARCHIVE_TIME, Emojis, Guild, Roles from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message @@ -95,6 +95,12 @@ class Reviewer: for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): await last_message.add_reaction(reaction) + thread = await last_message.create_thread( + name=f"Nomination - {nominee}", + auto_archive_duration=DEFAULT_THREAD_ARCHIVE_TIME + ) + await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>") + if update_database: nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) @@ -210,6 +216,13 @@ class Reviewer: colour=colour )) + # Thread channel IDs are the same as the message ID of the parent message. + nomination_thread = message.guild.get_thread(message.id) + if not nomination_thread: + log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") + return + await nomination_thread.edit(archived=True) + for message_ in messages: await message_.delete() -- cgit v1.2.3 From 6bd2a56d43d70476d18c5fd66da20d8cf1518373 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 18:52:03 +0000 Subject: Update nomination message regex --- bot/exts/recruitment/talentpool/_review.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index bc5cccda1..8b61a0eb5 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -36,9 +36,8 @@ MAX_MESSAGE_SIZE = 2000 MAX_EMBED_SIZE = 4000 # Regex for finding the first message of a nomination, and extracting the nominee. -# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this. NOMINATION_MESSAGE_REGEX = re.compile( - r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*", + r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n", re.MULTILINE ) -- cgit v1.2.3 From 0a4ba0b5d6341bc8cef13a30e35af5b4dc24248b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 4 Nov 2021 15:50:14 +0000 Subject: Supress NotFound when archiving a nomination This supresses both the mesage deleteions and the thread archive, so that if they are removed before the code can get to them, it does not raise an error. --- bot/exts/recruitment/talentpool/_review.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 8b61a0eb5..fab126408 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -10,7 +10,7 @@ from typing import List, Optional, Union import arrow from dateutil.parser import isoparse -from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel +from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, PartialMessage, TextChannel from discord.ext.commands import Context from bot.api import ResponseCodeError @@ -220,10 +220,13 @@ class Reviewer: if not nomination_thread: log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") return - await nomination_thread.edit(archived=True) for message_ in messages: - await message_.delete() + with contextlib.suppress(NotFound): + await message_.delete() + + with contextlib.suppress(NotFound): + await nomination_thread.edit(archived=True) async def _construct_review_body(self, member: Member) -> str: """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" -- cgit v1.2.3 From e62ff5b4d0cd811e40d54e94ae5ae6d48f934624 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 00:19:42 +0000 Subject: Ensure a nomination archival emoji isn't from the bot This is most relevant in local dev testing where the Emojis.check_mark could be the same as the Emojis.incident_actioned or Emojis.incident_unactioned, which would cause the bot to attempt to archive the post_review invocation if it was posted in the nomination voting channel. --- bot/exts/recruitment/talentpool/_cog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 699d60f42..615a95d20 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -516,6 +516,9 @@ class TalentPool(Cog, name="Talentpool"): if payload.channel_id != Channels.nomination_voting: return + if payload.user_id == self.bot.user.id: + return + message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id) emoji = str(payload.emoji) -- cgit v1.2.3 From 96911a9c9b6e833e68fb2ead081d12da4ca5ffd9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 00:54:31 +0000 Subject: Fix emoji reaction error in reviewer Using a :eyes: style emoji string in a ctx.add_reaciton call will error. Discord expects either a unicode emoji, or a custom emoji. --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index fab126408..eced33738 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -375,10 +375,10 @@ class Reviewer: @staticmethod def _random_ducky(guild: Guild) -> Union[Emoji, str]: - """Picks a random ducky emoji. If no duckies found returns :eyes:.""" + """Picks a random ducky emoji. If no duckies found returns 👀.""" duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] if not duckies: - return ":eyes:" + return "\N{EYES}" return random.choice(duckies) @staticmethod -- cgit v1.2.3 From 108bf3276b49de4e6153a2c7f96c731907e3ca37 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:04:14 +0000 Subject: Always return a review string for a given nomination --- bot/exts/recruitment/talentpool/_cog.py | 7 ++----- bot/exts/recruitment/talentpool/_review.py | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 615a95d20..ce0b2862f 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -484,11 +484,8 @@ class TalentPool(Cog, name="Talentpool"): async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" review, _, _ = await self.reviewer.make_review(user_id) - if review: - file = discord.File(StringIO(review), f"{user_id}_review.md") - await ctx.send(file=file) - else: - await ctx.send(f"There doesn't appear to be an active nomination for {user_id}") + file = discord.File(StringIO(review), f"{user_id}_review.md") + await ctx.send(file=file) @nomination_group.command(aliases=('review',)) @has_any_role(*MODERATION_ROLES) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index eced33738..a68169351 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -104,8 +104,8 @@ class Reviewer: nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) - async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: - """Format a generic review of a user and return it with the reviewed emoji.""" + async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]: + """Format a generic review of a user and return it with the reviewed emoji and the user themselves.""" log.trace(f"Formatting the review of {user_id}") # Since `cache` is a defaultdict, we should take care @@ -115,7 +115,7 @@ class Reviewer: nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") - return "", None + return f"There doesn't appear to be an active nomination for {user_id}", None, None guild = self.bot.get_guild(Guild.id) nominee = await get_or_fetch_member(guild, user_id) -- cgit v1.2.3 From 8c89ef922c5445f93e26e69ea4a65e5a2ceaf79e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:05:17 +0000 Subject: Use presence of a nominee as check for pending reviews --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index a68169351..110ac47bc 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -78,7 +78,7 @@ class Reviewer: async def post_review(self, user_id: int, update_database: bool) -> None: """Format the review of a user and post it to the nomination voting channel.""" review, reviewed_emoji, nominee = await self.make_review(user_id) - if not review: + if not nominee: return guild = self.bot.get_guild(Guild.id) -- cgit v1.2.3 From 6af87373ee3b97509d67ab611780c7e7892f4545 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:05:36 +0000 Subject: Remove redundant Union in a type hint --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index ce0b2862f..8fa0be5b1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -498,7 +498,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: + async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None: """Remove `user` from the talent pool after they are banned.""" await self.end_nomination(user.id, "User was banned.") -- cgit v1.2.3 From 8408fb5686a7af43ee9ee9f8c192574e34a5f931 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 2 Dec 2021 00:44:25 +0200 Subject: Dynamic views for command help embeds (#1939) Dynamic views for command help embeds Adds views for commands to navigate groups. For subcommands, a button is added to show the parent's help embed. For groups, buttons are added for each subcommand to show their help embeds. The views are not generated when help is invoked in the context of an error. --- bot/exts/backend/error_handler.py | 13 ++- bot/exts/info/help.py | 147 ++++++++++++++++++++++++--- tests/bot/exts/backend/test_error_handler.py | 32 ------ 3 files changed, 141 insertions(+), 51 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 6ab6634a6..5bef72808 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,4 @@ import difflib -import typing as t from discord import Embed from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors @@ -97,13 +96,14 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: + async def send_command_help(self, ctx: Context) -> None: """Return a prepared `help` command invocation coroutine.""" if ctx.command: - return ctx.send_help(ctx.command) + self.bot.help_command.context = ctx + await ctx.send_help(ctx.command) + return - return ctx.send_help() + await ctx.send_help() async def try_silence(self, ctx: Context) -> bool: """ @@ -245,7 +245,6 @@ class ErrorHandler(Cog): elif isinstance(e, errors.ArgumentParsingError): embed = self._get_error_embed("Argument parsing error", str(e)) await ctx.send(embed=embed) - self.get_help_command(ctx).close() self.bot.stats.incr("errors.argument_parsing_error") return else: @@ -256,7 +255,7 @@ class ErrorHandler(Cog): self.bot.stats.incr("errors.other_user_input_error") await ctx.send(embed=embed) - await self.get_help_command(ctx) + await self.send_command_help(ctx) @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 743dfdd3f..06799fb71 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import itertools import re from collections import namedtuple from contextlib import suppress -from typing import List, Union +from typing import List, Optional, Union -from discord import Colour, Embed +from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand from rapidfuzz import fuzz, process from rapidfuzz.utils import default_process @@ -26,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" Category = namedtuple("Category", ["name", "description", "cogs"]) +class SubcommandButton(ui.Button): + """ + A button shown in a group's help embed. + + The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.primary, + label: Optional[str] = None, + disabled: bool = False, + custom_id: Optional[str] = None, + url: Optional[str] = None, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + row: Optional[int] = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the subcommand.""" + message = interaction.message + if not message: + return + + subcommand = self.command + if isinstance(subcommand, Group): + embed, subcommand_view = await self.help_command.format_group_help(subcommand) + else: + embed, subcommand_view = await self.help_command.command_formatting(subcommand) + await message.edit(embed=embed, view=subcommand_view) + + +class GroupButton(ui.Button): + """ + A button shown in a subcommand's help embed. + + The button represents the parent command, and pressing it will edit the help embed to that of the parent. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.secondary, + label: Optional[str] = None, + disabled: bool = False, + custom_id: Optional[str] = None, + url: Optional[str] = None, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + row: Optional[int] = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the parent.""" + message = interaction.message + if not message: + return + + embed, group_view = await self.help_command.format_group_help(self.command.parent) + await message.edit(embed=embed, view=group_view) + + +class CommandView(ui.View): + """ + The view added to any command's help embed. + + If the command has a parent, a button is added to the view to show that parent's help embed. + """ + + def __init__(self, help_command: CustomHelpCommand, command: Command): + super().__init__() + + if command.parent: + self.children.append(GroupButton(help_command, command, emoji="â†Šī¸")) + + +class GroupView(CommandView): + """ + The view added to a group's help embed. + + The view generates a SubcommandButton for every subcommand the group has. + """ + + MAX_BUTTONS_IN_ROW = 5 + MAX_ROWS = 5 + + def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]): + super().__init__(help_command, group) + # Don't add buttons if only a portion of the subcommands can be shown. + if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW: + log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.") + return + + for subcommand in subcommands: + self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name)) + + class HelpQueryNotFound(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -148,7 +263,7 @@ class CustomHelpCommand(HelpCommand): await self.context.send(embed=embed) - async def command_formatting(self, command: Command) -> Embed: + async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]: """ Takes a command and turns it into an embed. @@ -186,12 +301,14 @@ class CustomHelpCommand(HelpCommand): command_details += f"*{formatted_doc or 'No details provided.'}*\n" embed.description = command_details - return embed + # If the help is invoked in the context of an error, don't show subcommand navigation. + view = CommandView(self, command) if not self.context.command_failed else None + return embed, view async def send_command_help(self, command: Command) -> None: """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) + embed, view = await self.command_formatting(command) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) @staticmethod @@ -212,25 +329,31 @@ class CustomHelpCommand(HelpCommand): else: return "".join(details) - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" + async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]: + """Formats help for a group command.""" subcommands = group.commands if len(subcommands) == 0: # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return + return await self.command_formatting(group) # remove commands that the user can't run and are hidden, and sort by name commands_ = await self.filter_commands(subcommands, sort=True) - embed = await self.command_formatting(group) + embed, _ = await self.command_formatting(group) command_details = self.get_commands_brief_details(commands_) if command_details: embed.description += f"\n**Subcommands:**\n{command_details}" - message = await self.context.send(embed=embed) + # If the help is invoked in the context of an error, don't show subcommand navigation. + view = GroupView(self, group, commands_) if not self.context.command_failed else None + return embed, view + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + embed, view = await self.format_group_help(group) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) async def send_cog_help(self, cog: Cog) -> None: diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 462f718e6..d12329b1f 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -572,38 +572,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): push_scope_mock.set_extra.has_calls(set_extra_calls) -class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): - """Other `ErrorHandler` tests.""" - - def setUp(self): - self.bot = MockBot() - self.ctx = MockContext() - - async def test_get_help_command_command_specified(self): - """Should return coroutine of help command of specified command.""" - self.ctx.command = "foo" - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help("foo") - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected - - async def test_get_help_command_no_command_specified(self): - """Should return coroutine of help command.""" - self.ctx.command = None - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help() - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected - - class ErrorHandlerSetupTests(unittest.TestCase): """Tests for `ErrorHandler` `setup` function.""" -- cgit v1.2.3