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:  |