aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Boris Muratov <[email protected]>2022-08-19 22:20:24 +0300
committerGravatar GitHub <[email protected]>2022-08-19 22:20:24 +0300
commit511671eeb05a4c0c6b600b291e4073f5358d6dcc (patch)
treeb53611b6e265fd0a85792c957a1bdd66ba417906
parentDuration for DM changed to Edited flag (diff)
parentMerge pull request #2260 from Dorukyum/channel.guild-nullable (diff)
Merge branch 'main' into infraction-durations
-rw-r--r--bot/exts/filters/filtering.py2
-rw-r--r--bot/exts/moderation/incidents.py23
-rw-r--r--bot/exts/moderation/infraction/infractions.py7
-rw-r--r--bot/exts/moderation/modlog.py2
-rw-r--r--bot/rules/mentions.py56
-rw-r--r--tests/bot/exts/moderation/test_incidents.py49
-rw-r--r--tests/bot/rules/test_mentions.py58
-rw-r--r--tests/helpers.py22
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.