aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/filtering/_filter_context.py3
-rw-r--r--bot/exts/filtering/_filter_lists/filter_list.py6
-rw-r--r--bot/exts/filtering/_filter_lists/invite.py2
-rw-r--r--bot/exts/filtering/_filter_lists/token.py2
-rw-r--r--bot/exts/filtering/_filters/unique/rich_embed.py2
-rw-r--r--bot/exts/filtering/_settings_types/actions/infraction_and_notification.py44
-rw-r--r--bot/exts/filtering/_settings_types/actions/ping.py2
-rw-r--r--bot/exts/filtering/_settings_types/actions/remove_context.py (renamed from bot/exts/filtering/_settings_types/actions/delete_messages.py)57
-rw-r--r--bot/exts/filtering/_settings_types/validations/channel_scope.py2
-rw-r--r--bot/exts/filtering/_settings_types/validations/filter_dm.py3
-rw-r--r--bot/exts/filtering/_ui/ui.py22
-rw-r--r--bot/exts/filtering/_utils.py35
-rw-r--r--bot/exts/filtering/filtering.py56
13 files changed, 176 insertions, 60 deletions
diff --git a/bot/exts/filtering/_filter_context.py b/bot/exts/filtering/_filter_context.py
index 4a213535a..61f8c9fbc 100644
--- a/bot/exts/filtering/_filter_context.py
+++ b/bot/exts/filtering/_filter_context.py
@@ -18,6 +18,7 @@ class Event(Enum):
MESSAGE = auto()
MESSAGE_EDIT = auto()
+ NICKNAME = auto()
@dataclass
@@ -27,7 +28,7 @@ class FilterContext:
# Input context
event: Event # The type of event
author: User | Member | None # Who triggered the event
- channel: TextChannel | Thread | DMChannel # The channel involved
+ channel: TextChannel | Thread | DMChannel | None # The channel involved
content: str | Iterable # What actually needs filtering
message: Message | None # The message involved
embeds: list = field(default_factory=list) # Any embeds involved
diff --git a/bot/exts/filtering/_filter_lists/filter_list.py b/bot/exts/filtering/_filter_lists/filter_list.py
index b5d6141d7..c829f4a8f 100644
--- a/bot/exts/filtering/_filter_lists/filter_list.py
+++ b/bot/exts/filtering/_filter_lists/filter_list.py
@@ -105,10 +105,10 @@ class AtomicList:
if ctx.event == Event.MESSAGE_EDIT and ctx.message and self.list_type == ListType.DENY:
previously_triggered = ctx.message_cache.get_message_metadata(ctx.message.id)
+ ignore_filters = previously_triggered[self]
+ # This updates the cache. Some filters are ignored, but they're necessary if there's another edit.
+ previously_triggered[self] = relevant_filters
if previously_triggered and self in previously_triggered:
- ignore_filters = previously_triggered[self]
- # This updates the cache. Some filters are ignored, but they're necessary if there's another edit.
- previously_triggered[self] = relevant_filters
relevant_filters = [filter_ for filter_ in relevant_filters if filter_ not in ignore_filters]
return relevant_filters
diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py
index 36031f276..dd14d2222 100644
--- a/bot/exts/filtering/_filter_lists/invite.py
+++ b/bot/exts/filtering/_filter_lists/invite.py
@@ -37,7 +37,7 @@ class InviteList(FilterList[InviteFilter]):
def __init__(self, filtering_cog: Filtering):
super().__init__()
- filtering_cog.subscribe(self, Event.MESSAGE)
+ filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT)
def get_filter_type(self, content: str) -> type[Filter]:
"""Get a subclass of filter matching the filter list and the filter's content."""
diff --git a/bot/exts/filtering/_filter_lists/token.py b/bot/exts/filtering/_filter_lists/token.py
index e4dbf4717..f5da28bb5 100644
--- a/bot/exts/filtering/_filter_lists/token.py
+++ b/bot/exts/filtering/_filter_lists/token.py
@@ -32,7 +32,7 @@ class TokensList(FilterList[TokenFilter]):
def __init__(self, filtering_cog: Filtering):
super().__init__()
- filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT)
+ filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT, Event.NICKNAME)
def get_filter_type(self, content: str) -> type[Filter]:
"""Get a subclass of filter matching the filter list and the filter's content."""
diff --git a/bot/exts/filtering/_filters/unique/rich_embed.py b/bot/exts/filtering/_filters/unique/rich_embed.py
index 5c3517e10..00c28e571 100644
--- a/bot/exts/filtering/_filters/unique/rich_embed.py
+++ b/bot/exts/filtering/_filters/unique/rich_embed.py
@@ -21,6 +21,8 @@ class RichEmbedFilter(UniqueFilter):
"""Determine if `msg` contains any rich embeds not auto-generated from a URL."""
if ctx.embeds:
if ctx.event == Event.MESSAGE_EDIT:
+ if not ctx.message.edited_at: # This might happen, apparently.
+ return False
# If the edit delta is less than 100 microseconds, it's probably a double filter trigger.
delta = ctx.message.edited_at - (ctx.before_message.edited_at or ctx.before_message.created_at)
if delta.total_seconds() < 0.0001:
diff --git a/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py
index b8b463626..f29aee571 100644
--- a/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py
+++ b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py
@@ -1,4 +1,3 @@
-from dataclasses import dataclass
from datetime import timedelta
from enum import Enum, auto
from typing import ClassVar
@@ -11,45 +10,14 @@ from discord.errors import Forbidden
from pydantic import validator
import bot as bot_module
-from bot.constants import Channels, Guild
+from bot.constants import Channels
from bot.exts.filtering._filter_context import FilterContext
from bot.exts.filtering._settings_types.settings_entry import ActionEntry
+from bot.exts.filtering._utils import FakeContext
log = get_logger(__name__)
-@dataclass
-class FakeContext:
- """
- A class representing a context-like object that can be sent to infraction commands.
-
- The goal is to be able to apply infractions without depending on the existence of a message or an interaction
- (which are the two ways to create a Context), e.g. in API events which aren't message-driven, or in custom filtering
- events.
- """
-
- channel: discord.abc.Messageable
- bot: bot_module.bot.Bot | None = None
- guild: discord.Guild | None = None
- author: discord.Member | discord.User | None = None
- me: discord.Member | None = None
-
- def __post_init__(self):
- """Initialize the missing information."""
- if not self.bot:
- self.bot = bot_module.instance
- if not self.guild:
- self.guild = self.bot.get_guild(Guild.id)
- if not self.me:
- self.me = self.guild.me
- if not self.author:
- self.author = self.me
-
- async def send(self, *args, **kwargs) -> discord.Message:
- """A wrapper for channel.send."""
- return await self.channel.send(*args, **kwargs)
-
-
class Infraction(Enum):
"""An enumeration of infraction types. The lower the value, the higher it is on the hierarchy."""
@@ -79,6 +47,8 @@ class Infraction(Enum):
command = bot_module.instance.get_command(command_name)
if not command:
await alerts_channel.send(f":warning: Could not apply {command_name} to {user.mention}: command not found.")
+ log.warning(f":warning: Could not apply {command_name} to {user.mention}: command not found.")
+ return
ctx = FakeContext(channel)
if self.name in ("KICK", "WARNING", "WATCH", "NOTE"):
@@ -160,8 +130,14 @@ class InfractionAndNotification(ActionEntry):
if not channel:
log.info(f"Could not find a channel with ID {self.infraction_channel}, infracting in mod-alerts.")
channel = alerts_channel
+ elif not ctx.channel:
+ channel = alerts_channel
else:
channel = ctx.channel
+ if not channel: # If somehow it's set to `alerts_channel` and it can't be found.
+ log.error(f"Unable to apply infraction as the context channel {channel} can't be found.")
+ return
+
await self.infraction_type.invoke(
ctx.author, channel, alerts_channel, self.infraction_duration, self.infraction_reason
)
diff --git a/bot/exts/filtering/_settings_types/actions/ping.py b/bot/exts/filtering/_settings_types/actions/ping.py
index 5597bdd59..b3725917c 100644
--- a/bot/exts/filtering/_settings_types/actions/ping.py
+++ b/bot/exts/filtering/_settings_types/actions/ping.py
@@ -35,7 +35,7 @@ class Ping(ActionEntry):
async def action(self, ctx: FilterContext) -> None:
"""Add the stored pings to the alert message content."""
- mentions = self.guild_pings if ctx.channel.guild else self.dm_pings
+ mentions = self.guild_pings if not ctx.channel or ctx.channel.guild else self.dm_pings
new_content = " ".join([resolve_mention(mention) for mention in mentions])
ctx.alert_content = f"{new_content} {ctx.alert_content}"
diff --git a/bot/exts/filtering/_settings_types/actions/delete_messages.py b/bot/exts/filtering/_settings_types/actions/remove_context.py
index 19c0beb95..7eb3db6c4 100644
--- a/bot/exts/filtering/_settings_types/actions/delete_messages.py
+++ b/bot/exts/filtering/_settings_types/actions/remove_context.py
@@ -2,14 +2,24 @@ from collections import defaultdict
from typing import ClassVar
from botcore.utils import scheduling
+from botcore.utils.logging import get_logger
from discord import Message
from discord.errors import HTTPException
+import bot
from bot.constants import Channels
-from bot.exts.filtering._filter_context import FilterContext
+from bot.exts.filtering._filter_context import Event, FilterContext
from bot.exts.filtering._settings_types.settings_entry import ActionEntry
+from bot.exts.filtering._utils import FakeContext
from bot.utils.messages import send_attachments
+log = get_logger(__name__)
+
+SUPERSTAR_REASON = (
+ "Your nickname was found to be in violation of our code of conduct. "
+ "If you believe this is a mistake, please let us know."
+)
+
async def upload_messages_attachments(ctx: FilterContext, messages: list[Message]) -> None:
"""Re-upload the messages' attachments for future logging."""
@@ -21,22 +31,31 @@ async def upload_messages_attachments(ctx: FilterContext, messages: list[Message
ctx.attachments[message.id] = await send_attachments(message, destination, link_large=False)
-class DeleteMessages(ActionEntry):
+class RemoveContext(ActionEntry):
"""A setting entry which tells whether to delete the offending message(s)."""
- name: ClassVar[str] = "delete_messages"
+ name: ClassVar[str] = "remove_context"
description: ClassVar[str] = (
- "A boolean field. If True, the filter being triggered will cause the offending message to be deleted."
+ "A boolean field. If True, the filter being triggered will cause the offending context to be removed. "
+ "An offending message will be deleted, while an offending nickname will be superstarified."
)
- delete_messages: bool
+ remove_context: bool
async def action(self, ctx: FilterContext) -> None:
- """Delete the context message(s)."""
- if not self.delete_messages or not ctx.message:
+ """Remove the offending context."""
+ if not self.remove_context:
return
- if not ctx.message.guild:
+ if ctx.event in (Event.MESSAGE, Event.MESSAGE_EDIT):
+ await self._handle_messages(ctx)
+ elif ctx.event == Event.NICKNAME:
+ await self._handle_nickname(ctx)
+
+ @staticmethod
+ async def _handle_messages(ctx: FilterContext) -> None:
+ """Delete any messages involved in this context."""
+ if not ctx.message or not ctx.message.guild:
return
channel_messages = defaultdict(set) # Duplicates will cause batch deletion to fail.
@@ -68,9 +87,27 @@ class DeleteMessages(ActionEntry):
else:
ctx.action_descriptions.append(f"{success} deleted, {fail} failed to delete")
+ @staticmethod
+ async def _handle_nickname(ctx: FilterContext) -> None:
+ """Apply a superstar infraction to remove the user's nickname."""
+ alerts_channel = bot.instance.get_channel(Channels.mod_alerts)
+ if not alerts_channel:
+ log.error(f"Unable to apply superstar as the context channel {alerts_channel} can't be found.")
+ return
+ command = bot.instance.get_command("superstar")
+ if not command:
+ user = ctx.author
+ await alerts_channel.send(f":warning: Could not apply superstar to {user.mention}: command not found.")
+ log.warning(f":warning: Could not apply superstar to {user.mention}: command not found.")
+ ctx.action_descriptions.append("failed to superstar")
+ return
+
+ await command(FakeContext(alerts_channel), ctx.author, None, reason=SUPERSTAR_REASON)
+ ctx.action_descriptions.append("superstar")
+
def __or__(self, other: ActionEntry):
"""Combines two actions of the same type. Each type of action is executed once per filter."""
- if not isinstance(other, DeleteMessages):
+ if not isinstance(other, RemoveContext):
return NotImplemented
- return DeleteMessages(delete_messages=self.delete_messages or other.delete_messages)
+ return RemoveContext(delete_messages=self.remove_context or other.remove_context)
diff --git a/bot/exts/filtering/_settings_types/validations/channel_scope.py b/bot/exts/filtering/_settings_types/validations/channel_scope.py
index 80f837a15..d37efaa09 100644
--- a/bot/exts/filtering/_settings_types/validations/channel_scope.py
+++ b/bot/exts/filtering/_settings_types/validations/channel_scope.py
@@ -51,6 +51,8 @@ class ChannelScope(ValidationEntry):
"""
channel = ctx.channel
+ if not channel:
+ return True
if not hasattr(channel, "category"): # This is not a guild channel, outside the scope of this setting.
return True
if hasattr(channel, "parent"):
diff --git a/bot/exts/filtering/_settings_types/validations/filter_dm.py b/bot/exts/filtering/_settings_types/validations/filter_dm.py
index b9e566253..9961984d6 100644
--- a/bot/exts/filtering/_settings_types/validations/filter_dm.py
+++ b/bot/exts/filtering/_settings_types/validations/filter_dm.py
@@ -14,4 +14,7 @@ class FilterDM(ValidationEntry):
def triggers_on(self, ctx: FilterContext) -> bool:
"""Return whether the filter should be triggered even if it was triggered in DMs."""
+ if not ctx.channel: # No channel - out of scope for this setting.
+ return True
+
return ctx.channel.guild is not None or self.filter_dm
diff --git a/bot/exts/filtering/_ui/ui.py b/bot/exts/filtering/_ui/ui.py
index 17a933783..ec549725c 100644
--- a/bot/exts/filtering/_ui/ui.py
+++ b/bot/exts/filtering/_ui/ui.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import re
from abc import ABC, abstractmethod
+from collections.abc import Iterable
from enum import EnumMeta
from functools import partial
from typing import Any, Callable, Coroutine, Optional, TypeVar
@@ -72,17 +73,21 @@ async def _build_alert_message_content(ctx: FilterContext, current_message_lengt
return alert_content
-async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList, list[str]]) -> Embed:
+async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> Embed:
"""Build an alert message from the filter context."""
embed = Embed(color=Colours.soft_orange)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
triggered_by = f"**Triggered by:** {format_user(ctx.author)}"
- if ctx.channel.guild:
- triggered_in = f"**Triggered in:** {format_channel(ctx.channel)}\n"
+ if ctx.channel:
+ if ctx.channel.guild:
+ triggered_in = f"**Triggered in:** {format_channel(ctx.channel)}\n"
+ else:
+ triggered_in = "**Triggered in:** :warning:**DM**:warning:\n"
+ if len(ctx.related_channels) > 1:
+ triggered_in += f"**Channels:** {', '.join(channel.mention for channel in ctx.related_channels)}\n"
else:
- triggered_in = "**Triggered in:** :warning:**DM**:warning:\n"
- if len(ctx.related_channels) > 1:
- triggered_in += f"**Channels:** {', '.join(channel.mention for channel in ctx.related_channels)}\n"
+ triggered_by += "\n"
+ triggered_in = ""
filters = []
for filter_list, list_message in triggered_filters.items():
@@ -94,7 +99,10 @@ async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList
actions = "\n**Actions Taken:** " + (", ".join(ctx.action_descriptions) if ctx.action_descriptions else "-")
mod_alert_message = "\n".join(part for part in (triggered_by, triggered_in, filters, matches, actions) if part)
- mod_alert_message += f"\n**[Original Content]({ctx.message.jump_url})**:\n"
+ if ctx.message:
+ mod_alert_message += f"\n**[Original Content]({ctx.message.jump_url})**:\n"
+ else:
+ mod_alert_message += "\n**Original Content**:\n"
mod_alert_message += await _build_alert_message_content(ctx, len(mod_alert_message))
embed.description = mod_alert_message
diff --git a/bot/exts/filtering/_utils.py b/bot/exts/filtering/_utils.py
index 86b6ab101..bd56c1260 100644
--- a/bot/exts/filtering/_utils.py
+++ b/bot/exts/filtering/_utils.py
@@ -4,12 +4,15 @@ import inspect
import pkgutil
from abc import ABC, abstractmethod
from collections import defaultdict
+from dataclasses import dataclass
from functools import cache
from typing import Any, Iterable, TypeVar, Union
+import discord
import regex
import bot
+from bot.bot import Bot
from bot.constants import Guild
VARIATION_SELECTORS = r"\uFE00-\uFE0F\U000E0100-\U000E01EF"
@@ -183,3 +186,35 @@ class FieldRequiring(ABC):
else:
# Add to the set of unique values for that field.
FieldRequiring.__unique_attributes[parent][attribute].add(value)
+
+
+@dataclass
+class FakeContext:
+ """
+ A class representing a context-like object that can be sent to infraction commands.
+
+ The goal is to be able to apply infractions without depending on the existence of a message or an interaction
+ (which are the two ways to create a Context), e.g. in API events which aren't message-driven, or in custom filtering
+ events.
+ """
+
+ channel: discord.abc.Messageable
+ bot: Bot | None = None
+ guild: discord.Guild | None = None
+ author: discord.Member | discord.User | None = None
+ me: discord.Member | None = None
+
+ def __post_init__(self):
+ """Initialize the missing information."""
+ if not self.bot:
+ self.bot = bot.instance
+ if not self.guild:
+ self.guild = self.bot.get_guild(Guild.id)
+ if not self.me:
+ self.me = self.guild.me
+ if not self.author:
+ self.author = self.me
+
+ async def send(self, *args, **kwargs) -> discord.Message:
+ """A wrapper for channel.send."""
+ return await self.channel.send(*args, **kwargs)
diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py
index 05b2339b9..9c9b1eff4 100644
--- a/bot/exts/filtering/filtering.py
+++ b/bot/exts/filtering/filtering.py
@@ -2,13 +2,17 @@ import datetime
import json
import operator
import re
+import unicodedata
from collections import defaultdict
+from collections.abc import Iterable
from functools import partial, reduce
from io import BytesIO
+from operator import attrgetter
from typing import Literal, Optional, get_type_hints
import arrow
import discord
+from async_rediscache import RedisCache
from botcore.site_api import ResponseCodeError
from discord import Colour, Embed, HTTPException, Message, MessageType
from discord.ext import commands, tasks
@@ -37,11 +41,13 @@ from bot.exts.filtering._utils import past_tense, repr_equals, starting_value, t
from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils.channel import is_mod_channel
+from bot.utils.lock import lock_arg
from bot.utils.message_cache import MessageCache
log = get_logger(__name__)
CACHE_SIZE = 100
+HOURS_BETWEEN_NICKNAME_ALERTS = 1
WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday
@@ -51,6 +57,9 @@ class Filtering(Cog):
# A set of filter list names with missing implementations that already caused a warning.
already_warned = set()
+ # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent.
+ name_alerts = RedisCache()
+
# region: init
def __init__(self, bot: Bot):
@@ -188,6 +197,10 @@ class Filtering(Cog):
if ctx.send_alert:
await self._send_alert(ctx, list_messages)
+ ctx = FilterContext.from_message(Event.NICKNAME, msg)
+ ctx.content = msg.author.display_name
+ await self._check_bad_name(ctx)
+
@Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
"""Filter the contents of an edited message. Don't reinvoke filters already invoked on the `before` version."""
@@ -209,6 +222,12 @@ class Filtering(Cog):
if ctx.send_alert:
await self._send_alert(ctx, list_messages)
+ @Cog.listener()
+ async def on_voice_state_update(self, member: discord.Member, *_) -> None:
+ """Checks for bad words in usernames when users join, switch or leave a voice channel."""
+ ctx = FilterContext(Event.NICKNAME, member, None, member.display_name, None)
+ await self._check_bad_name(ctx)
+
# endregion
# region: blacklist commands
@@ -388,7 +407,7 @@ class Filtering(Cog):
A template filter can be specified in the settings area to copy overrides from. The setting name is "--template"
and the value is the filter ID. The template will be used before applying any other override.
- Example: `!filter add denied token "Scaleios is great" delete_messages=True send_alert=False --template=100`
+ Example: `!filter add denied token "Scaleios is great" remove_context=True send_alert=False --template=100`
"""
result = await self._resolve_list_type_and_name(ctx, list_type, list_name)
if result is None:
@@ -809,7 +828,7 @@ class Filtering(Cog):
return result_actions, messages, triggers
- async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterList, list[str]]) -> None:
+ async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> None:
"""Build an alert message from the filter context, and send it via the alert webhook."""
if not self.webhook:
return
@@ -819,6 +838,39 @@ class Filtering(Cog):
# There shouldn't be more than 10, but if there are it's not very useful to send them all.
await self.webhook.send(username=name, content=ctx.alert_content, embeds=[embed, *ctx.alert_embeds][:10])
+ async def _recently_alerted_name(self, member: discord.Member) -> bool:
+ """When it hasn't been `HOURS_BETWEEN_NICKNAME_ALERTS` since last alert, return False, otherwise True."""
+ if last_alert := await self.name_alerts.get(member.id):
+ last_alert = arrow.get(last_alert)
+ if arrow.utcnow() - last_alert < datetime.timedelta(days=HOURS_BETWEEN_NICKNAME_ALERTS):
+ log.trace(f"Last alert was too recent for {member}'s nickname.")
+ return True
+
+ return False
+
+ @lock_arg("filtering.check_bad_name", "ctx", attrgetter("author.id"))
+ async def _check_bad_name(self, ctx: FilterContext) -> None:
+ """Check filter triggers in the passed context - a member's display name."""
+ if await self._recently_alerted_name(ctx.author):
+ return
+
+ name = ctx.content
+ normalised_name = unicodedata.normalize("NFKC", name)
+ cleaned_normalised_name = "".join([c for c in normalised_name if not unicodedata.combining(c)])
+
+ # Run filters against normalised, cleaned normalised and the original name,
+ # in case there are filters for one but not another.
+ names_to_check = (name, normalised_name, cleaned_normalised_name)
+
+ new_ctx = ctx.replace(content=" ".join(names_to_check))
+ result_actions, list_messages, _ = await self._resolve_action(new_ctx)
+ if result_actions:
+ await result_actions.action(ctx)
+ if ctx.send_alert:
+ await self._send_alert(ctx, list_messages) # `ctx` has the original content.
+ # Update time when alert sent
+ await self.name_alerts.set(ctx.author.id, arrow.utcnow().timestamp())
+
async def _resolve_list_type_and_name(
self, ctx: Context, list_type: ListType | None = None, list_name: str | None = None, *, exclude: str = ""
) -> tuple[ListType, FilterList] | None: