diff options
author | 2022-11-01 20:57:09 +0200 | |
---|---|---|
committer | 2022-11-01 20:57:09 +0200 | |
commit | c20398233a4a792e3207d52765aaf530a468351a (patch) | |
tree | df2f05962c594a6f02fcfdfbd01afd39430f22d8 | |
parent | Add antispam filter list and duplicates filter (diff) |
Add the rest of the antispam rules
This is mostly a copy-paste of the implementations in the old system into the new system's structure.
The mentions rule required changing the `triggers_on` method to async.
27 files changed, 468 insertions, 35 deletions
diff --git a/bot/exts/filtering/_filter_lists/antispam.py b/bot/exts/filtering/_filter_lists/antispam.py index 2dab54ce6..b2f873094 100644 --- a/bot/exts/filtering/_filter_lists/antispam.py +++ b/bot/exts/filtering/_filter_lists/antispam.py @@ -34,7 +34,9 @@ class AntispamList(UniquesListBase): """ A list of anti-spam rules. - Messages from the last X seconds is passed to each rule, which decide whether it triggers across those messages. + Messages from the last X seconds are passed to each rule, which decides whether it triggers across those messages. + + The infraction reason is set dynamically. """ name = "antispam" @@ -67,7 +69,7 @@ class AntispamList(UniquesListBase): takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.filtering_cog.message_cache) ) new_ctx = ctx.replace(content=relevant_messages) - triggers = sublist.filter_list_result(new_ctx) + triggers = await sublist.filter_list_result(new_ctx) if not triggers: return None, [] @@ -88,7 +90,9 @@ class AntispamList(UniquesListBase): # Smaller infraction value = higher in hierarchy. if not current_infraction or new_infraction.infraction_type.value < current_infraction.value: # Pick the first triggered filter for the reason, there's no good way to decide between them. - new_infraction.infraction_reason = f"{triggers[0].name} spam - {ctx.filter_info[triggers[0]]}" + new_infraction.infraction_reason = ( + f"{triggers[0].name.replace('_', ' ')} spam – {ctx.filter_info[triggers[0]]}" + ) current_actions["infraction_and_notification"] = new_infraction self.message_deletion_queue[ctx.author].current_infraction = new_infraction.infraction_type else: diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py index d97aa252b..0b56e8d73 100644 --- a/bot/exts/filtering/_filter_lists/domain.py +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -52,7 +52,7 @@ class DomainsList(FilterList[DomainFilter]): urls = {match.group(1).lower().rstrip("/") for match in URL_RE.finditer(text)} new_ctx = ctx.replace(content=urls) - triggers = self[ListType.DENY].filter_list_result(new_ctx) + triggers = await self[ListType.DENY].filter_list_result(new_ctx) ctx.notification_domain = new_ctx.notification_domain actions = None messages = [] diff --git a/bot/exts/filtering/_filter_lists/extension.py b/bot/exts/filtering/_filter_lists/extension.py index 3f9d2b287..a53520bf7 100644 --- a/bot/exts/filtering/_filter_lists/extension.py +++ b/bot/exts/filtering/_filter_lists/extension.py @@ -76,7 +76,9 @@ class ExtensionsList(FilterList[ExtensionFilter]): (splitext(attachment.filename.lower())[1], attachment.filename) for attachment in ctx.message.attachments } new_ctx = ctx.replace(content={ext for ext, _ in all_ext}) # And prepare the context for the filters to read. - triggered = [filter_ for filter_ in self[ListType.ALLOW].filters.values() if filter_.triggered_on(new_ctx)] + triggered = [ + filter_ for filter_ in self[ListType.ALLOW].filters.values() if await filter_.triggered_on(new_ctx) + ] allowed_ext = {filter_.content for filter_ in triggered} # Get the extensions in the message that are allowed. # See if there are any extensions left which aren't allowed. diff --git a/bot/exts/filtering/_filter_lists/filter_list.py b/bot/exts/filtering/_filter_lists/filter_list.py index f9db54a21..938766aca 100644 --- a/bot/exts/filtering/_filter_lists/filter_list.py +++ b/bot/exts/filtering/_filter_lists/filter_list.py @@ -65,7 +65,7 @@ class AtomicList: """Provide a short description identifying the list with its name and type.""" return f"{past_tense(self.list_type.name.lower())} {self.name.lower()}" - def filter_list_result(self, ctx: FilterContext) -> list[Filter]: + async def filter_list_result(self, ctx: FilterContext) -> list[Filter]: """ Sift through the list of filters, and return only the ones which apply to the given context. @@ -79,10 +79,12 @@ class AtomicList: If the filter is relevant in context, see if it actually triggers. """ - return self._create_filter_list_result(ctx, self.defaults, self.filters.values()) + return await self._create_filter_list_result(ctx, self.defaults, self.filters.values()) @staticmethod - def _create_filter_list_result(ctx: FilterContext, defaults: Defaults, filters: Iterable[Filter]) -> list[Filter]: + async def _create_filter_list_result( + ctx: FilterContext, defaults: Defaults, filters: Iterable[Filter] + ) -> list[Filter]: """A helper function to evaluate the result of `filter_list_result`.""" passed_by_default, failed_by_default = defaults.validations.evaluate(ctx) default_answer = not bool(failed_by_default) @@ -90,12 +92,12 @@ class AtomicList: relevant_filters = [] for filter_ in filters: if not filter_.validations: - if default_answer and filter_.triggered_on(ctx): + if default_answer and await filter_.triggered_on(ctx): relevant_filters.append(filter_) else: passed, failed = filter_.validations.evaluate(ctx) if not failed and failed_by_default < passed: - if filter_.triggered_on(ctx): + if await filter_.triggered_on(ctx): relevant_filters.append(filter_) return relevant_filters @@ -222,10 +224,10 @@ class SubscribingAtomicList(AtomicList): if filter_ not in self.subscriptions[event]: self.subscriptions[event].append(filter_.id) - def filter_list_result(self, ctx: FilterContext) -> list[Filter]: + async def filter_list_result(self, ctx: FilterContext) -> list[Filter]: """Sift through the list of filters, and return only the ones which apply to the given context.""" event_filters = [self.filters[id_] for id_ in self.subscriptions[ctx.event]] - return self._create_filter_list_result(ctx, self.defaults, event_filters) + return await self._create_filter_list_result(ctx, self.defaults, event_filters) class UniquesListBase(FilterList[UniqueFilter]): diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py index 0b84aec0e..911b951dd 100644 --- a/bot/exts/filtering/_filter_lists/invite.py +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -81,7 +81,7 @@ class InviteList(FilterList[InviteFilter]): # Find any blocked invites new_ctx = ctx.replace(content={invite.guild.id for invite in invites_for_inspection.values()}) - triggered = self[ListType.DENY].filter_list_result(new_ctx) + triggered = await self[ListType.DENY].filter_list_result(new_ctx) blocked_guilds = {filter_.content for filter_ in triggered} blocked_invites = { code: invite for code, invite in invites_for_inspection.items() if invite.guild.id in blocked_guilds @@ -100,7 +100,8 @@ class InviteList(FilterList[InviteFilter]): if check_if_allowed: # Whether unknown invites need to be checked. new_ctx = ctx.replace(content=guilds_for_inspection) allowed = { - filter_.content for filter_ in self[ListType.ALLOW].filters.values() if filter_.triggered_on(new_ctx) + filter_.content for filter_ in self[ListType.ALLOW].filters.values() + if await filter_.triggered_on(new_ctx) } unknown_invites.update({ code: invite for code, invite in invites_for_inspection.items() if invite.guild.id not in allowed diff --git a/bot/exts/filtering/_filter_lists/token.py b/bot/exts/filtering/_filter_lists/token.py index c7d7cb444..274dc5ea7 100644 --- a/bot/exts/filtering/_filter_lists/token.py +++ b/bot/exts/filtering/_filter_lists/token.py @@ -53,7 +53,7 @@ class TokensList(FilterList[TokenFilter]): text = clean_input(text) ctx = ctx.replace(content=text) - triggers = self[ListType.DENY].filter_list_result(ctx) + triggers = await self[ListType.DENY].filter_list_result(ctx) actions = None messages = [] if triggers: diff --git a/bot/exts/filtering/_filter_lists/unique.py b/bot/exts/filtering/_filter_lists/unique.py index 5204065f9..ecc49af87 100644 --- a/bot/exts/filtering/_filter_lists/unique.py +++ b/bot/exts/filtering/_filter_lists/unique.py @@ -31,7 +31,7 @@ class UniquesList(UniquesListBase): async def actions_for(self, ctx: FilterContext) -> tuple[ActionSettings | None, list[str]]: """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" - triggers = self[ListType.DENY].filter_list_result(ctx) + triggers = await self[ListType.DENY].filter_list_result(ctx) actions = None messages = [] if triggers: diff --git a/bot/exts/filtering/_filters/antispam/attachments.py b/bot/exts/filtering/_filters/antispam/attachments.py new file mode 100644 index 000000000..216d9b886 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/attachments.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraAttachmentsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of attachments before the filter is triggered." + + interval: int = 10 + threshold: int = 6 + + +class AttachmentsFilter(UniqueFilter): + """Detects too many attachments sent by a single user.""" + + name = "attachments" + events = (Event.MESSAGE,) + extra_fields_type = ExtraAttachmentsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author and len(msg.attachments) > 0} + total_recent_attachments = sum(len(msg.attachments) for msg in detected_messages) + + if total_recent_attachments > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_attachments} attachments" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/burst.py b/bot/exts/filtering/_filters/antispam/burst.py new file mode 100644 index 000000000..d78107d0a --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/burst.py @@ -0,0 +1,41 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraBurstSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of messages before the filter is triggered." + + interval: int = 10 + threshold: int = 7 + + +class BurstFilter(UniqueFilter): + """Detects too many messages sent by a single user.""" + + name = "burst" + events = (Event.MESSAGE,) + extra_fields_type = ExtraBurstSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + if len(detected_messages) > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {len(detected_messages)} messages" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/chars.py b/bot/exts/filtering/_filters/antispam/chars.py new file mode 100644 index 000000000..5c4fa201c --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/chars.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraCharsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of characters before the filter is triggered." + + interval: int = 5 + threshold: int = 4_200 + + +class CharsFilter(UniqueFilter): + """Detects too many characters sent by a single user.""" + + name = "chars" + events = (Event.MESSAGE,) + extra_fields_type = ExtraCharsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + total_recent_chars = sum(len(msg.content) for msg in relevant_messages) + + if total_recent_chars > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_chars} characters" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/duplicates.py b/bot/exts/filtering/_filters/antispam/duplicates.py index 5df2bb5c0..60d5c322c 100644 --- a/bot/exts/filtering/_filters/antispam/duplicates.py +++ b/bot/exts/filtering/_filters/antispam/duplicates.py @@ -15,7 +15,7 @@ class ExtraDuplicatesSettings(BaseModel): interval_description: ClassVar[str] = ( "Look for rule violations in messages from the last `interval` number of seconds." ) - threshold_description: ClassVar[str] = "Number of duplicate messages required to trigger the filter." + threshold_description: ClassVar[str] = "Maximum number of duplicate messages before the filter is triggered." interval: int = 10 threshold: int = 3 @@ -28,7 +28,7 @@ class DuplicatesFilter(UniqueFilter): events = (Event.MESSAGE,) extra_fields_type = ExtraDuplicatesSettings - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Search for the filter's content within a given context.""" earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) diff --git a/bot/exts/filtering/_filters/antispam/emoji.py b/bot/exts/filtering/_filters/antispam/emoji.py new file mode 100644 index 000000000..0511e4a7b --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/emoji.py @@ -0,0 +1,53 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from emoji import demojize +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) + + +class ExtraEmojiSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of emojis before the filter is triggered." + + interval: int = 10 + threshold: int = 20 + + +class EmojiFilter(UniqueFilter): + """Detects too many emojis sent by a single user.""" + + name = "emoji" + events = (Event.MESSAGE,) + extra_fields_type = ExtraEmojiSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + # Get rid of code blocks in the message before searching for emojis. + # Convert Unicode emojis to :emoji: format to get their count. + total_emojis = sum( + len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content)))) + for msg in relevant_messages + ) + + if total_emojis > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_emojis} emojis" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/links.py b/bot/exts/filtering/_filters/antispam/links.py new file mode 100644 index 000000000..76fe53e70 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/links.py @@ -0,0 +1,52 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +LINK_RE = re.compile(r"(https?://\S+)") + + +class ExtraLinksSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of links before the filter is triggered." + + interval: int = 10 + threshold: int = 10 + + +class DuplicatesFilter(UniqueFilter): + """Detects too many links sent by a single user.""" + + name = "links" + events = (Event.MESSAGE,) + extra_fields_type = ExtraLinksSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + total_links = 0 + messages_with_links = 0 + for msg in relevant_messages: + total_matches = len(LINK_RE.findall(msg.content)) + if total_matches: + messages_with_links += 1 + total_links += total_matches + + if total_links > self.extra_fields.threshold and messages_with_links > 1: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_links} links" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/mentions.py b/bot/exts/filtering/_filters/antispam/mentions.py new file mode 100644 index 000000000..29a2d5606 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/mentions.py @@ -0,0 +1,90 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from botcore.utils.logging import get_logger +from discord import DeletedReferencedMessage, MessageType, NotFound +from pydantic import BaseModel + +import bot +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +log = get_logger(__name__) + + +class ExtraMentionsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of distinct mentions before the filter is triggered." + + interval: int = 10 + threshold: int = 5 + + +class DuplicatesFilter(UniqueFilter): + """ + 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. + """ + + name = "mentions" + events = (Event.MESSAGE,) + extra_fields_type = ExtraMentionsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.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 + + 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 > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_mentions} mentions" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/newlines.py b/bot/exts/filtering/_filters/antispam/newlines.py new file mode 100644 index 000000000..b15a35219 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/newlines.py @@ -0,0 +1,61 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +NEWLINES = re.compile(r"(\n+)") + + +class ExtraNewlinesSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of newlines before the filter is triggered." + consecutive_threshold_description: ClassVar[str] = ( + "Maximum number of consecutive newlines before the filter is triggered." + ) + + interval: int = 10 + threshold: int = 100 + consecutive_threshold: int = 10 + + +class NewlinesFilter(UniqueFilter): + """Detects too many newlines sent by a single user.""" + + name = "newlines" + events = (Event.MESSAGE,) + extra_fields_type = ExtraNewlinesSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + # Identify groups of newline characters and get group & total counts + newline_counts = [] + for msg in relevant_messages: + newline_counts += [len(group) for group in NEWLINES.findall(msg.content)] + total_recent_newlines = sum(newline_counts) + # Get maximum newline group size + max_newline_group = max(newline_counts, default=0) + + # Check first for total newlines, if this passes then check for large groupings + if total_recent_newlines > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_newlines} newlines" + return True + if max_newline_group > self.extra_fields.consecutive_threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {max_newline_group} consecutive newlines" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/role_mentions.py b/bot/exts/filtering/_filters/antispam/role_mentions.py new file mode 100644 index 000000000..49de642fa --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/role_mentions.py @@ -0,0 +1,42 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraRoleMentionsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of role mentions before the filter is triggered." + + interval: int = 10 + threshold: int = 3 + + +class DuplicatesFilter(UniqueFilter): + """Detects too many role mentions sent by a single user.""" + + name = "role_mentions" + events = (Event.MESSAGE,) + extra_fields_type = ExtraRoleMentionsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + total_recent_mentions = sum(len(msg.role_mentions) for msg in relevant_messages) + + if total_recent_mentions > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_mentions} role mentions" + return True + return False diff --git a/bot/exts/filtering/_filters/domain.py b/bot/exts/filtering/_filters/domain.py index e22cafbb7..4cc3a6f5a 100644 --- a/bot/exts/filtering/_filters/domain.py +++ b/bot/exts/filtering/_filters/domain.py @@ -34,7 +34,7 @@ class DomainFilter(Filter): name = "domain" extra_fields_type = ExtraDomainSettings - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Searches for a domain within a given context.""" domain = tldextract.extract(self.content).registered_domain diff --git a/bot/exts/filtering/_filters/extension.py b/bot/exts/filtering/_filters/extension.py index 926a6a2fb..f3f64532f 100644 --- a/bot/exts/filtering/_filters/extension.py +++ b/bot/exts/filtering/_filters/extension.py @@ -11,7 +11,7 @@ class ExtensionFilter(Filter): name = "extension" - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Searches for an attachment extension in the context content, given as a set of extensions.""" return self.content in ctx.content diff --git a/bot/exts/filtering/_filters/filter.py b/bot/exts/filtering/_filters/filter.py index b0d19d3a8..4ae7ec45f 100644 --- a/bot/exts/filtering/_filters/filter.py +++ b/bot/exts/filtering/_filters/filter.py @@ -1,4 +1,4 @@ -from abc import ABC, abstractmethod +from abc import abstractmethod from typing import Any from pydantic import ValidationError @@ -48,7 +48,7 @@ class Filter(FieldRequiring): return settings, filter_settings @abstractmethod - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Search for the filter's content within a given context.""" @classmethod @@ -81,7 +81,7 @@ class Filter(FieldRequiring): return string -class UniqueFilter(Filter, ABC): +class UniqueFilter(Filter): """ Unique filters are ones that should only be run once in a given context. diff --git a/bot/exts/filtering/_filters/invite.py b/bot/exts/filtering/_filters/invite.py index ac4f62cb6..e8f3e9851 100644 --- a/bot/exts/filtering/_filters/invite.py +++ b/bot/exts/filtering/_filters/invite.py @@ -20,7 +20,7 @@ class InviteFilter(Filter): super().__init__(filter_data, defaults_data) self.content = int(self.content) - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Searches for a guild ID in the context content, given as a set of IDs.""" return self.content in ctx.content diff --git a/bot/exts/filtering/_filters/token.py b/bot/exts/filtering/_filters/token.py index 04e30cb03..f61d38846 100644 --- a/bot/exts/filtering/_filters/token.py +++ b/bot/exts/filtering/_filters/token.py @@ -11,7 +11,7 @@ class TokenFilter(Filter): name = "token" - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Searches for a regex pattern within a given context.""" pattern = self.content diff --git a/bot/exts/filtering/_filters/unique/discord_token.py b/bot/exts/filtering/_filters/unique/discord_token.py index 7fdb800df..731df198c 100644 --- a/bot/exts/filtering/_filters/unique/discord_token.py +++ b/bot/exts/filtering/_filters/unique/discord_token.py @@ -69,7 +69,7 @@ class DiscordTokenFilter(UniqueFilter): """Get currently loaded ModLog cog instance.""" return bot.instance.get_cog("ModLog") - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Return whether the message contains Discord client tokens.""" found_token = self.find_token_in_message(ctx.content) if not found_token: diff --git a/bot/exts/filtering/_filters/unique/everyone.py b/bot/exts/filtering/_filters/unique/everyone.py index 06d3a19bb..a32e67cc5 100644 --- a/bot/exts/filtering/_filters/unique/everyone.py +++ b/bot/exts/filtering/_filters/unique/everyone.py @@ -18,7 +18,7 @@ class EveryoneFilter(UniqueFilter): name = "everyone" events = (Event.MESSAGE, Event.MESSAGE_EDIT) - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Search for the filter's content within a given context.""" # First pass to avoid running re.sub on every message if not EVERYONE_PING_RE.search(ctx.content): diff --git a/bot/exts/filtering/_filters/unique/rich_embed.py b/bot/exts/filtering/_filters/unique/rich_embed.py index a0d9e263f..09d513373 100644 --- a/bot/exts/filtering/_filters/unique/rich_embed.py +++ b/bot/exts/filtering/_filters/unique/rich_embed.py @@ -17,7 +17,7 @@ class RichEmbedFilter(UniqueFilter): name = "rich_embed" events = (Event.MESSAGE, Event.MESSAGE_EDIT) - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Determine if `msg` contains any rich embeds not auto-generated from a URL.""" if ctx.embeds: for embed in ctx.embeds: diff --git a/bot/exts/filtering/_filters/unique/webhook.py b/bot/exts/filtering/_filters/unique/webhook.py index b9d98db35..16ff1b213 100644 --- a/bot/exts/filtering/_filters/unique/webhook.py +++ b/bot/exts/filtering/_filters/unique/webhook.py @@ -29,7 +29,7 @@ class WebhookFilter(UniqueFilter): """Get current instance of `ModLog`.""" return bot.instance.get_cog("ModLog") - def triggered_on(self, ctx: FilterContext) -> bool: + async def triggered_on(self, ctx: FilterContext) -> bool: """Search for a webhook in the given content. If found, attempt to delete it.""" matches = set(WEBHOOK_URL_RE.finditer(ctx.content)) if not matches: diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 514ef39e1..aad36af14 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -175,7 +175,6 @@ class Filtering(Cog): self.message_cache.append(msg) ctx = FilterContext(Event.MESSAGE, msg.author, msg.channel, msg.content, msg, msg.embeds) - result_actions, list_messages = await self._resolve_action(ctx) if result_actions: await result_actions.action(ctx) @@ -194,7 +193,7 @@ class Filtering(Cog): @blocklist.command(name="list", aliases=("get",)) async def bl_list(self, ctx: Context, list_name: Optional[str] = None) -> None: """List the contents of a specified blacklist.""" - result = self._resolve_list_type_and_name(ctx, ListType.DENY, list_name) + result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name) if not result: return list_type, filter_list = result @@ -237,7 +236,7 @@ class Filtering(Cog): @allowlist.command(name="list", aliases=("get",)) async def al_list(self, ctx: Context, list_name: Optional[str] = None) -> None: """List the contents of a specified whitelist.""" - result = self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name) + result = await self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name) if not result: return list_type, filter_list = result diff --git a/tests/bot/exts/filtering/test_filters.py b/tests/bot/exts/filtering/test_filters.py index 214637b52..29b50188a 100644 --- a/tests/bot/exts/filtering/test_filters.py +++ b/tests/bot/exts/filtering/test_filters.py @@ -5,7 +5,7 @@ from bot.exts.filtering._filters.token import TokenFilter from tests.helpers import MockMember, MockMessage, MockTextChannel -class FilterTests(unittest.TestCase): +class FilterTests(unittest.IsolatedAsyncioTestCase): """Test functionality of the token filter.""" def setUp(self) -> None: @@ -14,7 +14,7 @@ class FilterTests(unittest.TestCase): message = MockMessage(author=member, channel=channel) self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) - def test_token_filter_triggers(self): + async def test_token_filter_triggers(self): """The filter should evaluate to True only if its token is found in the context content.""" test_cases = ( (r"hi", "oh hi there", True), @@ -37,5 +37,5 @@ class FilterTests(unittest.TestCase): "additional_field": "{}" # noqa: P103 }) self.ctx.content = content - result = filter_.triggered_on(self.ctx) + result = await filter_.triggered_on(self.ctx) self.assertEqual(result, expected) |