diff options
| author | 2022-11-25 21:00:13 +0200 | |
|---|---|---|
| committer | 2022-11-25 21:00:13 +0200 | |
| commit | afd9d4ec993dba3c76ab8a8341b718c75d2a68e6 (patch) | |
| tree | 93f0a230b3bd56fe9d9edc724624f0e33c9cef35 | |
| parent | Add message edit filtering (diff) | |
Add nickname filter
The nickname filter works in much the same way as the one in the old system, with the following changes:
- The lock is per user, rather than a global lock.
- The alert cooldown is one hour, instead of three days which seemed too much.
The delete_messages setting was changed to the more generic remove_context.
If it's a nickname event, the context will be removed by applying a superstar infraction to the user.
In order to allow filtering nicknames in voice state events, the filter context can now have None in the channel field.
Additionally:
- Fixes a bug when ignoring filters in message edits.
- Makes the invites list keep track of message edits.
- The FakeContext class is moved to utils since it's now also needed by remove_context.
| -rw-r--r-- | bot/exts/filtering/_filter_context.py | 3 | ||||
| -rw-r--r-- | bot/exts/filtering/_filter_lists/filter_list.py | 6 | ||||
| -rw-r--r-- | bot/exts/filtering/_filter_lists/invite.py | 2 | ||||
| -rw-r--r-- | bot/exts/filtering/_filter_lists/token.py | 2 | ||||
| -rw-r--r-- | bot/exts/filtering/_filters/unique/rich_embed.py | 2 | ||||
| -rw-r--r-- | bot/exts/filtering/_settings_types/actions/infraction_and_notification.py | 44 | ||||
| -rw-r--r-- | bot/exts/filtering/_settings_types/actions/ping.py | 2 | ||||
| -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.py | 2 | ||||
| -rw-r--r-- | bot/exts/filtering/_settings_types/validations/filter_dm.py | 3 | ||||
| -rw-r--r-- | bot/exts/filtering/_ui/ui.py | 22 | ||||
| -rw-r--r-- | bot/exts/filtering/_utils.py | 35 | ||||
| -rw-r--r-- | bot/exts/filtering/filtering.py | 56 |
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: |