diff options
| -rw-r--r-- | bot/exts/filters/filtering.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/incidents.py | 23 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 7 | ||||
| -rw-r--r-- | bot/exts/moderation/modlog.py | 2 | ||||
| -rw-r--r-- | bot/rules/mentions.py | 56 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/test_incidents.py | 49 | ||||
| -rw-r--r-- | tests/bot/rules/test_mentions.py | 58 | ||||
| -rw-r--r-- | tests/helpers.py | 22 | 
8 files changed, 186 insertions, 33 deletions
| diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index ca6ad0064..e4df0b1fd 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -413,7 +413,7 @@ class Filtering(Cog):                              await context.invoke(                                  context.command,                                  msg.author, -                                arrow.utcnow() + AUTO_BAN_DURATION, +                                (arrow.utcnow() + AUTO_BAN_DURATION).datetime,                                  reason=AUTO_BAN_REASON                              ) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 155b123ca..1ddbe9857 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,6 +1,6 @@  import asyncio  import re -from datetime import datetime +from datetime import datetime, timezone  from enum import Enum  from typing import Optional @@ -13,6 +13,7 @@ from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks  from bot.log import get_logger  from bot.utils.messages import format_user, sub_clyde +from bot.utils.time import TimestampFormats, discord_timestamp  log = get_logger(__name__) @@ -25,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"(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,10 +98,20 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di          colour = Colours.soft_red          footer = f"Rejected by {actioned_by}" +    reported_timestamp = discord_timestamp(incident.created_at) +    relative_timestamp = discord_timestamp(incident.created_at, TimestampFormats.RELATIVE) +    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 +        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, +        timestamp=datetime.now(timezone.utc)      )      embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url) @@ -381,7 +392,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,              ) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 83a947a19..05cc74a03 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, DurationOrExpiry, 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 @@ -153,6 +155,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 with the ban reason and duration used for compromised accounts.""" +        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:          """ 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. 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/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 97682163f..53d98360c 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 @@ -12,12 +13,15 @@ import discord  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.base import RedisTestCase  from tests.helpers import (      MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel,      MockUser  ) +CURRENT_TIME = datetime.datetime(2022, 1, 1, tzinfo=datetime.timezone.utc) +  class MockAsyncIterable:      """ @@ -100,30 +104,45 @@ 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.""" -        incident = MockMessage(content="this is an incident") +        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) +          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 {reported_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."""          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)): @@ -135,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)): @@ -349,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. @@ -359,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): @@ -375,7 +397,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 @@ -406,7 +428,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"), 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"]) @@ -505,12 +527,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, created_at=CURRENT_TIME), +                member=mock_member              )          mock_task.assert_awaited() @@ -529,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: 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. | 
