aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar mbaruh <[email protected]>2022-11-01 20:57:09 +0200
committerGravatar mbaruh <[email protected]>2022-11-01 20:57:09 +0200
commitc20398233a4a792e3207d52765aaf530a468351a (patch)
treedf2f05962c594a6f02fcfdfbd01afd39430f22d8
parentAdd 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.
-rw-r--r--bot/exts/filtering/_filter_lists/antispam.py10
-rw-r--r--bot/exts/filtering/_filter_lists/domain.py2
-rw-r--r--bot/exts/filtering/_filter_lists/extension.py4
-rw-r--r--bot/exts/filtering/_filter_lists/filter_list.py16
-rw-r--r--bot/exts/filtering/_filter_lists/invite.py5
-rw-r--r--bot/exts/filtering/_filter_lists/token.py2
-rw-r--r--bot/exts/filtering/_filter_lists/unique.py2
-rw-r--r--bot/exts/filtering/_filters/antispam/attachments.py43
-rw-r--r--bot/exts/filtering/_filters/antispam/burst.py41
-rw-r--r--bot/exts/filtering/_filters/antispam/chars.py43
-rw-r--r--bot/exts/filtering/_filters/antispam/duplicates.py4
-rw-r--r--bot/exts/filtering/_filters/antispam/emoji.py53
-rw-r--r--bot/exts/filtering/_filters/antispam/links.py52
-rw-r--r--bot/exts/filtering/_filters/antispam/mentions.py90
-rw-r--r--bot/exts/filtering/_filters/antispam/newlines.py61
-rw-r--r--bot/exts/filtering/_filters/antispam/role_mentions.py42
-rw-r--r--bot/exts/filtering/_filters/domain.py2
-rw-r--r--bot/exts/filtering/_filters/extension.py2
-rw-r--r--bot/exts/filtering/_filters/filter.py6
-rw-r--r--bot/exts/filtering/_filters/invite.py2
-rw-r--r--bot/exts/filtering/_filters/token.py2
-rw-r--r--bot/exts/filtering/_filters/unique/discord_token.py2
-rw-r--r--bot/exts/filtering/_filters/unique/everyone.py2
-rw-r--r--bot/exts/filtering/_filters/unique/rich_embed.py2
-rw-r--r--bot/exts/filtering/_filters/unique/webhook.py2
-rw-r--r--bot/exts/filtering/filtering.py5
-rw-r--r--tests/bot/exts/filtering/test_filters.py6
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)