From 8c46657aefeea118e90ada3ec9a61ba9c4734162 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 29 Dec 2021 15:08:58 +0000 Subject: Incident archive improvements --- bot/exts/moderation/incidents.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 77dfad255..bd9e5b88e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,6 +1,5 @@ import asyncio import re -from datetime import datetime from enum import Enum from typing import Optional @@ -13,6 +12,7 @@ from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks from bot.log import get_logger from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde +from bot.utils.time import TimestampFormats, discord_timestamp log = get_logger(__name__) @@ -25,9 +25,9 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/" + r"(https?://(?:(ptb|canary|www)\.)?discord(?:app)?\.com/channels/" r"[0-9]{15,20}" - r"\/[0-9]{15,20}\/[0-9]{15,20})" + r"/[0-9]{15,20}/[0-9]{15,20})" ) @@ -97,9 +97,19 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di colour = Colours.soft_red footer = f"Rejected by {actioned_by}" + day_timestamp = discord_timestamp(incident.created_at, TimestampFormats.DATE) + time_timestamp = discord_timestamp(incident.created_at, TimestampFormats.TIME) + relative_timestamp = discord_timestamp(incident.created_at, TimestampFormats.RELATIVE) + reported_on_msg = f"__*Reported {day_timestamp} at {time_timestamp} ({relative_timestamp}).*__" + + # If the description will be too long (>4096 total characters), truncate the incident content + if len(incident.content) > (allowed_content_chars := 4096-len(reported_on_msg)-2): # -2 for the newlines + description = incident.content[:allowed_content_chars-3] + f"...\n\n{reported_on_msg}" + else: + description = incident.content + f"\n\n{reported_on_msg}" + embed = discord.Embed( - description=incident.content, - timestamp=datetime.utcnow(), + description=description, colour=colour, ) embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url) @@ -380,7 +390,7 @@ class Incidents(Cog): webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) await webhook.send( embed=embed, - username=sub_clyde(incident.author.name), + username=sub_clyde(incident.author.display_name), avatar_url=incident.author.display_avatar.url, file=attachment_file, ) -- cgit v1.2.3 From 1e0c0cfe37eb9a868454508fcb813d7cf19e12cc Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 29 Dec 2021 15:09:22 +0000 Subject: Fix tests --- tests/bot/exts/moderation/test_incidents.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cfe0c4b03..ef33aa62b 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -1,4 +1,5 @@ import asyncio +import datetime import enum import logging import typing as t @@ -13,6 +14,7 @@ from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents from bot.utils.messages import format_user +from bot.utils.time import TimestampFormats, discord_timestamp from tests.helpers import ( MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel, MockUser @@ -114,10 +116,19 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): async def test_make_embed_content(self): """Incident content appears as embed description.""" - incident = MockMessage(content="this is an incident") + current_time = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + incident = MockMessage(content="this is an incident", created_at=current_time) + + day_timestamp = discord_timestamp(current_time, TimestampFormats.DATE) + time_timestamp = discord_timestamp(current_time, TimestampFormats.TIME) + relative_timestamp = discord_timestamp(current_time, TimestampFormats.RELATIVE) + embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) - self.assertEqual(incident.content, embed.description) + self.assertEqual( + f"{incident.content}\n\n__*Reported {day_timestamp} at {time_timestamp} ({relative_timestamp}).*__", + embed.description + ) async def test_make_embed_with_attachment_succeeds(self): """Incident's attachment is downloaded and displayed in the embed's image field.""" @@ -391,7 +402,7 @@ class TestArchive(TestIncidents): # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", - author=MockUser(name="author_name", display_avatar=Mock(url="author_avatar")), + author=MockUser(display_name="author_name", display_avatar=Mock(url="author_avatar")), id=123, ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this @@ -422,7 +433,7 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + message_from_clyde = MockMessage(author=MockUser(display_name="clyde the great")) await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) @@ -521,12 +532,13 @@ class TestProcessEvent(TestIncidents): async def test_process_event_confirmation_task_is_awaited(self): """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" mock_task = AsyncMock() + mock_member = MockMember(display_name="Bobby Johnson", roles=[MockRole(id=1)]) 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(id=123), - member=MockMember(roles=[MockRole(id=1)]) + incident=MockMessage(author=mock_member, id=123), + member=mock_member ) mock_task.assert_awaited() -- cgit v1.2.3 From b7e03616ac3fc0b5e8a5a77a352df593983d187a Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 14 Jul 2022 22:21:34 +0100 Subject: Address Reviews - Use the more concise DATETIME timestamp instead of both a DATE and a TIME timestamp. - Remove underline from the "Reported ..." section at the bottom of the embed. - Re-add time of action/rejection timestamp to footer of embed. --- bot/exts/moderation/incidents.py | 7 ++++--- tests/bot/exts/moderation/test_incidents.py | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index bd9e5b88e..f29cfcdd6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,5 +1,6 @@ import asyncio import re +from datetime import datetime, timezone from enum import Enum from typing import Optional @@ -97,10 +98,9 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di colour = Colours.soft_red footer = f"Rejected by {actioned_by}" - day_timestamp = discord_timestamp(incident.created_at, TimestampFormats.DATE) - time_timestamp = discord_timestamp(incident.created_at, TimestampFormats.TIME) + reported_timestamp = discord_timestamp(incident.created_at) relative_timestamp = discord_timestamp(incident.created_at, TimestampFormats.RELATIVE) - reported_on_msg = f"__*Reported {day_timestamp} at {time_timestamp} ({relative_timestamp}).*__" + reported_on_msg = f"*Reported {reported_timestamp} ({relative_timestamp}).*" # If the description will be too long (>4096 total characters), truncate the incident content if len(incident.content) > (allowed_content_chars := 4096-len(reported_on_msg)-2): # -2 for the newlines @@ -111,6 +111,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di embed = discord.Embed( description=description, colour=colour, + timestamp=datetime.now(timezone.utc) ) embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index ef33aa62b..da0a79ce8 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -119,14 +119,13 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): current_time = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) incident = MockMessage(content="this is an incident", created_at=current_time) - day_timestamp = discord_timestamp(current_time, TimestampFormats.DATE) - time_timestamp = discord_timestamp(current_time, TimestampFormats.TIME) + reported_timestamp = discord_timestamp(current_time) relative_timestamp = discord_timestamp(current_time, TimestampFormats.RELATIVE) embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) self.assertEqual( - f"{incident.content}\n\n__*Reported {day_timestamp} at {time_timestamp} ({relative_timestamp}).*__", + f"{incident.content}\n\n*Reported {reported_timestamp} ({relative_timestamp}).*", embed.description ) -- cgit v1.2.3 From 1df6034a1723fd3ff1bd88047ca6a62f920767e6 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 15 Aug 2022 12:04:33 +0100 Subject: Fix incident tests. --- tests/bot/exts/moderation/test_incidents.py | 38 +++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 11fe565fc..53d98360c 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -20,6 +20,8 @@ from tests.helpers import ( MockUser ) +CURRENT_TIME = datetime.datetime(2022, 1, 1, tzinfo=datetime.timezone.utc) + class MockAsyncIterable: """ @@ -102,25 +104,32 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): async def test_make_embed_actioned(self): """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + embed, file = await incidents.make_embed( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=incidents.Signal.ACTIONED, + actioned_by=MockMember() + ) self.assertEqual(embed.colour.value, Colours.soft_green) self.assertIn("Actioned", embed.footer.text) async def test_make_embed_not_actioned(self): """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + embed, file = await incidents.make_embed( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=incidents.Signal.NOT_ACTIONED, + actioned_by=MockMember() + ) self.assertEqual(embed.colour.value, Colours.soft_red) self.assertIn("Rejected", embed.footer.text) async def test_make_embed_content(self): """Incident content appears as embed description.""" - current_time = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) - incident = MockMessage(content="this is an incident", created_at=current_time) + incident = MockMessage(content="this is an incident", created_at=CURRENT_TIME) - reported_timestamp = discord_timestamp(current_time) - relative_timestamp = discord_timestamp(current_time, TimestampFormats.RELATIVE) + reported_timestamp = discord_timestamp(CURRENT_TIME) + relative_timestamp = discord_timestamp(CURRENT_TIME, TimestampFormats.RELATIVE) embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) @@ -133,7 +142,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): """Incident's attachment is downloaded and displayed in the embed's image field.""" file = MagicMock(discord.File, filename="bigbadjoe.jpg") attachment = MockAttachment(filename="bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) + incident = MockMessage(content="this is an incident", attachments=[attachment], created_at=CURRENT_TIME) # Patch `download_file` to return our `file` with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)): @@ -145,7 +154,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): async def test_make_embed_with_attachment_fails(self): """Incident's attachment fails to download, proxy url is linked instead.""" attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) + incident = MockMessage(content="this is an incident", attachments=[attachment], created_at=CURRENT_TIME) # Patch `download_file` to return None as if the download failed with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)): @@ -359,7 +368,6 @@ class TestCrawlIncidents(TestIncidents): class TestArchive(TestIncidents): """Tests for the `Incidents.archive` coroutine.""" - async def test_archive_webhook_not_found(self): """ Method recovers and returns False when the webhook is not found. @@ -369,7 +377,11 @@ class TestArchive(TestIncidents): """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) self.assertFalse( - await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + await self.cog_instance.archive( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=MagicMock(), + actioned_by=MockMember() + ) ) async def test_archive_relays_incident(self): @@ -416,7 +428,7 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - message_from_clyde = MockMessage(author=MockUser(display_name="clyde the great")) + message_from_clyde = MockMessage(author=MockUser(display_name="clyde the great"), created_at=CURRENT_TIME) await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) @@ -520,7 +532,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(author=mock_member, id=123), + incident=MockMessage(author=mock_member, id=123, created_at=CURRENT_TIME), member=mock_member ) @@ -540,7 +552,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(id=123), + incident=MockMessage(id=123, created_at=CURRENT_TIME), member=MockMember(roles=[MockRole(id=1)]) ) except asyncio.TimeoutError: -- cgit v1.2.3 From fce6ea2f845889ab8eb6801e25e56ef0b7e67f3d Mon Sep 17 00:00:00 2001 From: dawnofmidnight Date: Mon, 15 Aug 2022 12:32:46 -0400 Subject: feat: command for banning compromised accounts --- bot/exts/moderation/infraction/infractions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 46fd3381c..679768268 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,6 +1,7 @@ import textwrap import typing as t +import arrow import discord from discord import Member from discord.ext import commands @@ -11,6 +12,7 @@ from bot.bot import Bot from bot.constants import Event from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy +from bot.exts.filters.filtering import AUTO_BAN_DURATION, AUTO_BAN_REASON from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.log import get_logger @@ -151,6 +153,11 @@ class Infractions(InfractionScheduler, commands.Cog): ctx.send = send await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})") + @command() + async def compban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + """Same as cleanban, but specifically for four days and with the ban reason used for compromised accounts.""" + await self.cleanban(ctx, user, duration=arrow.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON) + @command(aliases=("vban",)) async def voiceban(self, ctx: Context) -> None: """ -- cgit v1.2.3 From a7234a824f3d4b2d052357d73d23a6e0c5cbb1b6 Mon Sep 17 00:00:00 2001 From: dawnofmidnight Date: Mon, 15 Aug 2022 14:38:01 -0400 Subject: fix: change use of arrow to datetime and change docstring wording --- bot/exts/filters/filtering.py | 4 ++-- bot/exts/moderation/infraction/infractions.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index ca6ad0064..51877d7f0 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,7 +2,7 @@ import asyncio import re import unicodedata import urllib.parse -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union import arrow @@ -413,7 +413,7 @@ class Filtering(Cog): await context.invoke( context.command, msg.author, - arrow.utcnow() + AUTO_BAN_DURATION, + datetime.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON ) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 679768268..c70ff7705 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,7 +1,7 @@ import textwrap import typing as t +from datetime import datetime -import arrow import discord from discord import Member from discord.ext import commands @@ -155,8 +155,8 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def compban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: - """Same as cleanban, but specifically for four days and with the ban reason used for compromised accounts.""" - await self.cleanban(ctx, user, duration=arrow.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON) + """Same as cleanban, but specifically with the ban reason and duration used for compromised accounts.""" + await self.cleanban(ctx, user, duration=datetime.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON) @command(aliases=("vban",)) async def voiceban(self, ctx: Context) -> None: -- cgit v1.2.3 From 4eaecb32830aa122cd53656de562caf20b79e33d Mon Sep 17 00:00:00 2001 From: dawnofmidnight Date: Wed, 17 Aug 2022 17:02:25 -0400 Subject: fix: replace datetime.utcnow() with arrow --- bot/exts/filters/filtering.py | 4 ++-- bot/exts/moderation/infraction/infractions.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 51877d7f0..e4df0b1fd 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,7 +2,7 @@ import asyncio import re import unicodedata import urllib.parse -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union import arrow @@ -413,7 +413,7 @@ class Filtering(Cog): await context.invoke( context.command, msg.author, - datetime.utcnow() + AUTO_BAN_DURATION, + (arrow.utcnow() + AUTO_BAN_DURATION).datetime, reason=AUTO_BAN_REASON ) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index c70ff7705..08a3609a7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,7 +1,7 @@ import textwrap import typing as t -from datetime import datetime +import arrow import discord from discord import Member from discord.ext import commands @@ -156,7 +156,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def compban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Same as cleanban, but specifically with the ban reason and duration used for compromised accounts.""" - await self.cleanban(ctx, user, duration=datetime.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON) + await self.cleanban(ctx, user, duration=(arrow.utcnow() + AUTO_BAN_DURATION).datetime, reason=AUTO_BAN_REASON) @command(aliases=("vban",)) async def voiceban(self, ctx: Context) -> None: -- cgit v1.2.3 From 8767bf6d73bf1e61f8047a4090930fcf0c08f42c Mon Sep 17 00:00:00 2001 From: Dorukyum <53639936+Dorukyum@users.noreply.github.com> Date: Thu, 18 Aug 2022 19:02:05 +0300 Subject: Check if channel.guild is None --- 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 67991730e..efa87ce25 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -552,7 +552,7 @@ class ModLog(Cog, name="ModLog"): channel = self.bot.get_channel(channel_id) # Ignore not found channels, DMs, and messages outside of the main guild. - if not channel or not hasattr(channel, "guild") or channel.guild.id != GuildConstant.id: + if not channel or channel.guild is None or channel.guild.id != GuildConstant.id: return True # Look at the parent channel of a thread. -- cgit v1.2.3 From dada405211eac996196cdfb0496f4ff22f9a656a Mon Sep 17 00:00:00 2001 From: arl Date: Thu, 18 Aug 2022 19:01:22 -0400 Subject: fix: don't include replied mentions in mention filter (#2017) Co-authored-by: Izan Co-authored-by: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/rules/mentions.py | 56 +++++++++++++++++++++++++++++++++----- tests/bot/rules/test_mentions.py | 58 ++++++++++++++++++++++++++++++++++++---- tests/helpers.py | 22 +++++++++++++++ 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 6f5addad1..ca1d0c01c 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -1,23 +1,65 @@ from typing import Dict, Iterable, List, Optional, Tuple -from discord import Member, Message +from discord import DeletedReferencedMessage, Member, Message, MessageType, NotFound + +import bot +from bot.log import get_logger + +log = get_logger(__name__) async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total mentions exceeding the limit sent by a single user.""" + """ + Detects total mentions exceeding the limit sent by a single user. + + Excludes mentions that are bots, themselves, or replied users. + + In very rare cases, may not be able to determine a + mention was to a reply, in which case it is not ignored. + """ relevant_messages = tuple( msg for msg in recent_messages if msg.author == last_message.author ) + # We use `msg.mentions` here as that is supplied by the api itself, to determine who was mentioned. + # Additionally, `msg.mentions` includes the user replied to, even if the mention doesn't occur in the body. + # In order to exclude users who are mentioned as a reply, we check if the msg has a reference + # + # While we could use regex to parse the message content, and get a list of + # the mentions, that solution is very prone to breaking. + # We would need to deal with codeblocks, escaping markdown, and any discrepancies between + # our implementation and discord's markdown parser which would cause false positives or false negatives. + total_recent_mentions = 0 + for msg in relevant_messages: + # We check if the message is a reply, and if it is try to get the author + # since we ignore mentions of a user that we're replying to + reply_author = None - total_recent_mentions = sum( - not user.bot - for msg in relevant_messages - for user in msg.mentions - ) + if msg.type == MessageType.reply: + ref = msg.reference + + if not (resolved := ref.resolved): + # It is possible, in a very unusual situation, for a message to have a reference + # that is both not in the cache, and deleted while running this function. + # In such a situation, this will throw an error which we catch. + try: + resolved = await bot.instance.get_partial_messageable(resolved.channel_id).fetch_message( + resolved.message_id + ) + except NotFound: + log.info('Could not fetch the reference message as it has been deleted.') + + if resolved and not isinstance(resolved, DeletedReferencedMessage): + reply_author = resolved.author + + for user in msg.mentions: + # Don't count bot or self mentions, or the user being replied to (if applicable) + if user.bot or user in {msg.author, reply_author}: + continue + total_recent_mentions += 1 if total_recent_mentions > config['max']: return ( diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index f8805ac48..e1f904917 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -1,15 +1,32 @@ -from typing import Iterable +from typing import Iterable, Optional + +import discord from bot.rules import mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMember, MockMessage +from tests.helpers import MockMember, MockMessage, MockMessageReference -def make_msg(author: str, total_user_mentions: int, total_bot_mentions: int = 0) -> MockMessage: - """Makes a message with `total_mentions` mentions.""" +def make_msg( + author: str, + total_user_mentions: int, + total_bot_mentions: int = 0, + *, + reference: Optional[MockMessageReference] = None +) -> MockMessage: + """Makes a message from `author` with `total_user_mentions` user mentions and `total_bot_mentions` bot mentions.""" user_mentions = [MockMember() for _ in range(total_user_mentions)] bot_mentions = [MockMember(bot=True) for _ in range(total_bot_mentions)] - return MockMessage(author=author, mentions=user_mentions+bot_mentions) + + mentions = user_mentions + bot_mentions + if reference is not None: + # For the sake of these tests we assume that all references are mentions. + mentions.append(reference.resolved.author) + msg_type = discord.MessageType.reply + else: + msg_type = discord.MessageType.default + + return MockMessage(author=author, mentions=mentions, reference=reference, type=msg_type) class TestMentions(RuleTest): @@ -56,6 +73,16 @@ class TestMentions(RuleTest): ("bob",), 3, ), + DisallowedCase( + [make_msg("bob", 3, reference=MockMessageReference())], + ("bob",), + 3, + ), + DisallowedCase( + [make_msg("bob", 3, reference=MockMessageReference(reference_author_is_bot=True))], + ("bob",), + 3 + ) ) await self.run_disallowed(cases) @@ -71,6 +98,27 @@ class TestMentions(RuleTest): await self.run_allowed(cases) + async def test_ignore_reply_mentions(self): + """Messages with an allowed amount of mentions in the content, also containing reply mentions.""" + cases = ( + [ + make_msg("bob", 2, reference=MockMessageReference()) + ], + [ + make_msg("bob", 2, reference=MockMessageReference(reference_author_is_bot=True)) + ], + [ + make_msg("bob", 2, reference=MockMessageReference()), + make_msg("bob", 0, reference=MockMessageReference()) + ], + [ + make_msg("bob", 2, reference=MockMessageReference(reference_author_is_bot=True)), + make_msg("bob", 0, reference=MockMessageReference(reference_author_is_bot=True)) + ] + ) + + await self.run_allowed(cases) + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: last_message = case.recent_messages[0] return tuple( diff --git a/tests/helpers.py b/tests/helpers.py index 17214553c..687e15b96 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -492,6 +492,28 @@ class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): spec_set = attachment_instance +message_reference_instance = discord.MessageReference( + message_id=unittest.mock.MagicMock(id=1), + channel_id=unittest.mock.MagicMock(id=2), + guild_id=unittest.mock.MagicMock(id=3) +) + + +class MockMessageReference(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock MessageReference objects. + + Instances of this class will follow the specification of `discord.MessageReference` instances. + For more information, see the `MockGuild` docstring. + """ + spec_set = message_reference_instance + + def __init__(self, *, reference_author_is_bot: bool = False, **kwargs): + super().__init__(**kwargs) + referenced_msg_author = MockMember(name="bob", bot=reference_author_is_bot) + self.resolved = MockMessage(author=referenced_msg_author) + + class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. -- cgit v1.2.3