From 72e164c38fed8d02fbe58412cf3a6de6e38aec09 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 30 Sep 2022 23:45:49 +0300 Subject: Split actions and validations to their own packcages This is a purely aesthetic choice. Additionally fixes a small bug where a missing entry type would repeatedly invoke a warning on cog load. --- bot/exts/filtering/_settings.py | 2 +- bot/exts/filtering/_settings_types/__init__.py | 9 +- .../filtering/_settings_types/actions/__init__.py | 8 ++ .../_settings_types/actions/delete_messages.py | 35 ++++++ .../actions/infraction_and_notification.py | 137 +++++++++++++++++++++ bot/exts/filtering/_settings_types/actions/ping.py | 70 +++++++++++ .../_settings_types/actions/send_alert.py | 24 ++++ bot/exts/filtering/_settings_types/bypass_roles.py | 33 ----- .../filtering/_settings_types/channel_scope.py | 66 ---------- .../filtering/_settings_types/delete_messages.py | 35 ------ bot/exts/filtering/_settings_types/enabled.py | 19 --- bot/exts/filtering/_settings_types/filter_dm.py | 17 --- .../_settings_types/infraction_and_notification.py | 137 --------------------- bot/exts/filtering/_settings_types/ping.py | 70 ----------- bot/exts/filtering/_settings_types/send_alert.py | 24 ---- .../_settings_types/validations/__init__.py | 8 ++ .../_settings_types/validations/bypass_roles.py | 33 +++++ .../_settings_types/validations/channel_scope.py | 66 ++++++++++ .../_settings_types/validations/enabled.py | 19 +++ .../_settings_types/validations/filter_dm.py | 17 +++ tests/bot/exts/filtering/test_settings_entries.py | 8 +- 21 files changed, 424 insertions(+), 413 deletions(-) create mode 100644 bot/exts/filtering/_settings_types/actions/__init__.py create mode 100644 bot/exts/filtering/_settings_types/actions/delete_messages.py create mode 100644 bot/exts/filtering/_settings_types/actions/infraction_and_notification.py create mode 100644 bot/exts/filtering/_settings_types/actions/ping.py create mode 100644 bot/exts/filtering/_settings_types/actions/send_alert.py delete mode 100644 bot/exts/filtering/_settings_types/bypass_roles.py delete mode 100644 bot/exts/filtering/_settings_types/channel_scope.py delete mode 100644 bot/exts/filtering/_settings_types/delete_messages.py delete mode 100644 bot/exts/filtering/_settings_types/enabled.py delete mode 100644 bot/exts/filtering/_settings_types/filter_dm.py delete mode 100644 bot/exts/filtering/_settings_types/infraction_and_notification.py delete mode 100644 bot/exts/filtering/_settings_types/ping.py delete mode 100644 bot/exts/filtering/_settings_types/send_alert.py create mode 100644 bot/exts/filtering/_settings_types/validations/__init__.py create mode 100644 bot/exts/filtering/_settings_types/validations/bypass_roles.py create mode 100644 bot/exts/filtering/_settings_types/validations/channel_scope.py create mode 100644 bot/exts/filtering/_settings_types/validations/enabled.py create mode 100644 bot/exts/filtering/_settings_types/validations/filter_dm.py diff --git a/bot/exts/filtering/_settings.py b/bot/exts/filtering/_settings.py index f88b26ee3..cbd682d6d 100644 --- a/bot/exts/filtering/_settings.py +++ b/bot/exts/filtering/_settings.py @@ -31,7 +31,7 @@ def create_settings( action_data[entry_name] = entry_data elif entry_name in settings_types["ValidationEntry"]: validation_data[entry_name] = entry_data - else: + elif entry_name not in _already_warned: log.warning( f"A setting named {entry_name} was loaded from the database, but no matching class." ) diff --git a/bot/exts/filtering/_settings_types/__init__.py b/bot/exts/filtering/_settings_types/__init__.py index 620290cb2..61b5737d4 100644 --- a/bot/exts/filtering/_settings_types/__init__.py +++ b/bot/exts/filtering/_settings_types/__init__.py @@ -1,10 +1,5 @@ -from os.path import dirname - -from bot.exts.filtering._settings_types.settings_entry import ActionEntry, ValidationEntry -from bot.exts.filtering._utils import subclasses_in_package - -action_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ActionEntry) -validation_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ValidationEntry) +from bot.exts.filtering._settings_types.actions import action_types +from bot.exts.filtering._settings_types.validations import validation_types settings_types = { "ActionEntry": {settings_type.name: settings_type for settings_type in action_types}, diff --git a/bot/exts/filtering/_settings_types/actions/__init__.py b/bot/exts/filtering/_settings_types/actions/__init__.py new file mode 100644 index 000000000..a8175b976 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/__init__.py @@ -0,0 +1,8 @@ +from os.path import dirname + +from bot.exts.filtering._settings_types.settings_entry import ActionEntry +from bot.exts.filtering._utils import subclasses_in_package + +action_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ActionEntry) + +__all__ = [action_types] diff --git a/bot/exts/filtering/_settings_types/actions/delete_messages.py b/bot/exts/filtering/_settings_types/actions/delete_messages.py new file mode 100644 index 000000000..710cb0ed8 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/delete_messages.py @@ -0,0 +1,35 @@ +from contextlib import suppress +from typing import ClassVar + +from discord.errors import NotFound + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry + + +class DeleteMessages(ActionEntry): + """A setting entry which tells whether to delete the offending message(s).""" + + name: ClassVar[str] = "delete_messages" + description: ClassVar[str] = ( + "A boolean field. If True, the filter being triggered will cause the offending message to be deleted." + ) + + delete_messages: bool + + async def action(self, ctx: FilterContext) -> None: + """Delete the context message(s).""" + if not self.delete_messages or ctx.event not in (Event.MESSAGE, Event.MESSAGE_EDIT): + return + + with suppress(NotFound): + if ctx.message.guild: + await ctx.message.delete() + ctx.action_descriptions.append("deleted") + + 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): + return NotImplemented + + return DeleteMessages(delete_messages=self.delete_messages or other.delete_messages) diff --git a/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py new file mode 100644 index 000000000..4fcf2aa65 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py @@ -0,0 +1,137 @@ +from datetime import timedelta +from enum import Enum, auto +from typing import ClassVar + +import arrow +from discord import Colour, Embed +from discord.errors import Forbidden +from pydantic import validator + +import bot +from bot.constants import Channels, Guild +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry + + +class Infraction(Enum): + """An enumeration of infraction types. The lower the value, the higher it is on the hierarchy.""" + + BAN = auto() + KICK = auto() + MUTE = auto() + VOICE_MUTE = auto() + SUPERSTAR = auto() + WARNING = auto() + WATCH = auto() + NOTE = auto() + + def __str__(self) -> str: + return self.name + + +class InfractionAndNotification(ActionEntry): + """ + A setting entry which specifies what infraction to issue and the notification to DM the user. + + Since a DM cannot be sent when a user is banned or kicked, these two functions need to be grouped together. + """ + + name: ClassVar[str] = "infraction_and_notification" + description: ClassVar[dict[str, str]] = { + "infraction_type": ( + "The type of infraction to issue when the filter triggers, or 'NONE'. " + "If two infractions are triggered for the same message, " + "the harsher one will be applied (by type or duration). " + "Superstars will be triggered even if there is a harsher infraction.\n\n" + "Valid infraction types in order of harshness: " + ) + ", ".join(infraction.name for infraction in Infraction), + "infraction_duration": "How long the infraction should last for in seconds, or 'None' for permanent.", + "infraction_reason": "The reason delivered with the infraction.", + "dm_content": "The contents of a message to be DMed to the offending user.", + "dm_embed": "The contents of the embed to be DMed to the offending user." + } + + dm_content: str | None + dm_embed: str | None + infraction_type: Infraction | None + infraction_reason: str | None + infraction_duration: float | None + + @validator("infraction_type", pre=True) + @classmethod + def convert_infraction_name(cls, infr_type: str) -> Infraction: + """Convert the string to an Infraction by name.""" + return Infraction[infr_type.replace(" ", "_").upper()] if infr_type else None + + async def action(self, ctx: FilterContext) -> None: + """Send the notification to the user, and apply any specified infractions.""" + # If there is no infraction to apply, any DM contents already provided in the context take precedence. + if self.infraction_type is None and (ctx.dm_content or ctx.dm_embed): + dm_content = ctx.dm_content + dm_embed = ctx.dm_embed + else: + dm_content = self.dm_content + dm_embed = self.dm_embed + + if dm_content or dm_embed: + formatting = {"domain": ctx.notification_domain} + dm_content = f"Hey {ctx.author.mention}!\n{dm_content.format(**formatting)}" + if dm_embed: + dm_embed = Embed(description=dm_embed.format(**formatting), colour=Colour.og_blurple()) + else: + dm_embed = None + + try: + await ctx.author.send(dm_content, embed=dm_embed) + ctx.action_descriptions.append("notified") + except Forbidden: + ctx.action_descriptions.append("notified (failed)") + + msg_ctx = await bot.instance.get_context(ctx.message) + msg_ctx.guild = bot.instance.get_guild(Guild.id) + msg_ctx.author = ctx.author + msg_ctx.channel = ctx.channel + + if self.infraction_type is not None: + if self.infraction_type == Infraction.BAN or not hasattr(ctx.channel, "guild"): + msg_ctx.channel = bot.instance.get_channel(Channels.mod_alerts) + msg_ctx.command = bot.instance.get_command(self.infraction_type.name.lower()) + await msg_ctx.invoke( + msg_ctx.command, + ctx.author, + arrow.utcnow() + timedelta(seconds=self.infraction_duration) + if self.infraction_duration is not None else None, + reason=self.infraction_reason + ) + ctx.action_descriptions.append(self.infraction_type.name.lower()) + + def __or__(self, other: ActionEntry): + """ + Combines two actions of the same type. Each type of action is executed once per filter. + + If the infractions are different, take the data of the one higher up the hierarchy. + + There is no clear way to properly combine several notification messages, especially when it's in two parts. + To avoid bombarding the user with several notifications, the message with the more significant infraction + is used. + """ + if not isinstance(other, InfractionAndNotification): + return NotImplemented + + # Lower number -> higher in the hierarchy + if self.infraction_type is None: + return other.copy() + elif other.infraction_type is None: + return self.copy() + elif self.infraction_type.value < other.infraction_type.value: + return self.copy() + elif self.infraction_type.value > other.infraction_type.value: + return other.copy() + else: + if self.infraction_duration is None or ( + other.infraction_duration is not None and self.infraction_duration > other.infraction_duration + ): + result = self.copy() + else: + result = other.copy() + return result diff --git a/bot/exts/filtering/_settings_types/actions/ping.py b/bot/exts/filtering/_settings_types/actions/ping.py new file mode 100644 index 000000000..0bfc12809 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/ping.py @@ -0,0 +1,70 @@ +from functools import cache +from typing import ClassVar + +from discord import Guild +from pydantic import validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry + + +class Ping(ActionEntry): + """A setting entry which adds the appropriate pings to the alert.""" + + name: ClassVar[str] = "mentions" + description: ClassVar[dict[str, str]] = { + "guild_pings": ( + "A list of role IDs/role names/user IDs/user names/here/everyone. " + "If a mod-alert is generated for a filter triggered in a public channel, these will be pinged." + ), + "dm_pings": ( + "A list of role IDs/role names/user IDs/user names/here/everyone. " + "If a mod-alert is generated for a filter triggered in DMs, these will be pinged." + ) + } + + guild_pings: set[str] + dm_pings: set[str] + + @validator("*") + @classmethod + def init_sequence_if_none(cls, pings: list[str]) -> list[str]: + """Initialize an empty sequence if the value is None.""" + if pings is None: + return [] + return pings + + 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 + new_content = " ".join([self._resolve_mention(mention, ctx.channel.guild) for mention in mentions]) + ctx.alert_content = f"{new_content} {ctx.alert_content}" + + 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, Ping): + return NotImplemented + + return Ping(guild_pings=self.guild_pings | other.guild_pings, dm_pings=self.dm_pings | other.dm_pings) + + @staticmethod + @cache + def _resolve_mention(mention: str, guild: Guild) -> str: + """Return the appropriate formatting for the formatting, be it a literal, a user ID, or a role ID.""" + if mention in ("here", "everyone"): + return f"@{mention}" + if mention.isdigit(): # It's an ID. + mention = int(mention) + if any(mention == role.id for role in guild.roles): + return f"<@&{mention}>" + else: + return f"<@{mention}>" + + # It's a name + for role in guild.roles: + if role.name == mention: + return role.mention + for member in guild.members: + if str(member) == mention: + return member.mention + return mention diff --git a/bot/exts/filtering/_settings_types/actions/send_alert.py b/bot/exts/filtering/_settings_types/actions/send_alert.py new file mode 100644 index 000000000..04e400764 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/send_alert.py @@ -0,0 +1,24 @@ +from typing import ClassVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry + + +class SendAlert(ActionEntry): + """A setting entry which tells whether to send an alert message.""" + + name: ClassVar[str] = "send_alert" + description: ClassVar[str] = "A boolean. If all filters triggered set this to False, no mod-alert will be created." + + send_alert: bool + + async def action(self, ctx: FilterContext) -> None: + """Add the stored pings to the alert message content.""" + ctx.send_alert = self.send_alert + + 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, SendAlert): + return NotImplemented + + return SendAlert(send_alert=self.send_alert or other.send_alert) diff --git a/bot/exts/filtering/_settings_types/bypass_roles.py b/bot/exts/filtering/_settings_types/bypass_roles.py deleted file mode 100644 index a5c18cffc..000000000 --- a/bot/exts/filtering/_settings_types/bypass_roles.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import ClassVar, Union - -from discord import Member -from pydantic import validator - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ValidationEntry - - -class RoleBypass(ValidationEntry): - """A setting entry which tells whether the roles the member has allow them to bypass the filter.""" - - name: ClassVar[str] = "bypass_roles" - description: ClassVar[str] = "A list of role IDs or role names. Users with these roles will not trigger the filter." - - bypass_roles: set[Union[int, str]] - - @validator("bypass_roles", each_item=True) - @classmethod - def maybe_cast_to_int(cls, role: str) -> Union[int, str]: - """If the string is alphanumeric, cast it to int.""" - if role.isdigit(): - return int(role) - return role - - def triggers_on(self, ctx: FilterContext) -> bool: - """Return whether the filter should be triggered on this user given their roles.""" - if not isinstance(ctx.author, Member): - return True - return all( - member_role.id not in self.bypass_roles and member_role.name not in self.bypass_roles - for member_role in ctx.author.roles - ) diff --git a/bot/exts/filtering/_settings_types/channel_scope.py b/bot/exts/filtering/_settings_types/channel_scope.py deleted file mode 100644 index fd5206b81..000000000 --- a/bot/exts/filtering/_settings_types/channel_scope.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import ClassVar, Union - -from pydantic import validator - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ValidationEntry - - -class ChannelScope(ValidationEntry): - """A setting entry which tells whether the filter was invoked in a whitelisted channel or category.""" - - name: ClassVar[str] = "channel_scope" - description: ClassVar[str] = { - "disabled_channels": "A list of channel IDs or channel names. The filter will not trigger in these channels.", - "disabled_categories": ( - "A list of category IDs or category names. The filter will not trigger in these categories." - ), - "enabled_channels": ( - "A list of channel IDs or channel names. " - "The filter can trigger in these channels even if the category is disabled." - ) - } - - disabled_channels: set[Union[str, int]] - disabled_categories: set[Union[str, int]] - enabled_channels: set[Union[str, int]] - - @validator("*", pre=True) - @classmethod - def init_if_sequence_none(cls, sequence: list[str]) -> list[str]: - """Initialize an empty sequence if the value is None.""" - if sequence is None: - return [] - return sequence - - @validator("*", each_item=True) - @classmethod - def maybe_cast_items(cls, channel_or_category: str) -> Union[str, int]: - """Cast to int each value in each sequence if it is alphanumeric.""" - if channel_or_category.isdigit(): - return int(channel_or_category) - return channel_or_category - - def triggers_on(self, ctx: FilterContext) -> bool: - """ - Return whether the filter should be triggered in the given channel. - - The filter is invoked by default. - If the channel is explicitly enabled, it bypasses the set disabled channels and categories. - """ - channel = ctx.channel - enabled_id = ( - channel.id in self.enabled_channels - or ( - channel.id not in self.disabled_channels - and (not channel.category or channel.category.id not in self.disabled_categories) - ) - ) - enabled_name = ( - channel.name in self.enabled_channels - or ( - channel.name not in self.disabled_channels - and (not channel.category or channel.category.name not in self.disabled_categories) - ) - ) - return enabled_id and enabled_name diff --git a/bot/exts/filtering/_settings_types/delete_messages.py b/bot/exts/filtering/_settings_types/delete_messages.py deleted file mode 100644 index 710cb0ed8..000000000 --- a/bot/exts/filtering/_settings_types/delete_messages.py +++ /dev/null @@ -1,35 +0,0 @@ -from contextlib import suppress -from typing import ClassVar - -from discord.errors import NotFound - -from bot.exts.filtering._filter_context import Event, FilterContext -from bot.exts.filtering._settings_types.settings_entry import ActionEntry - - -class DeleteMessages(ActionEntry): - """A setting entry which tells whether to delete the offending message(s).""" - - name: ClassVar[str] = "delete_messages" - description: ClassVar[str] = ( - "A boolean field. If True, the filter being triggered will cause the offending message to be deleted." - ) - - delete_messages: bool - - async def action(self, ctx: FilterContext) -> None: - """Delete the context message(s).""" - if not self.delete_messages or ctx.event not in (Event.MESSAGE, Event.MESSAGE_EDIT): - return - - with suppress(NotFound): - if ctx.message.guild: - await ctx.message.delete() - ctx.action_descriptions.append("deleted") - - 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): - return NotImplemented - - return DeleteMessages(delete_messages=self.delete_messages or other.delete_messages) diff --git a/bot/exts/filtering/_settings_types/enabled.py b/bot/exts/filtering/_settings_types/enabled.py deleted file mode 100644 index 3b5e3e446..000000000 --- a/bot/exts/filtering/_settings_types/enabled.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import ClassVar - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ValidationEntry - - -class Enabled(ValidationEntry): - """A setting entry which tells whether the filter is enabled.""" - - name: ClassVar[str] = "enabled" - description: ClassVar[str] = ( - "A boolean field. Setting it to False allows disabling the filter without deleting it entirely." - ) - - enabled: bool - - def triggers_on(self, ctx: FilterContext) -> bool: - """Return whether the filter is enabled.""" - return self.enabled diff --git a/bot/exts/filtering/_settings_types/filter_dm.py b/bot/exts/filtering/_settings_types/filter_dm.py deleted file mode 100644 index 93022320f..000000000 --- a/bot/exts/filtering/_settings_types/filter_dm.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import ClassVar - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ValidationEntry - - -class FilterDM(ValidationEntry): - """A setting entry which tells whether to apply the filter to DMs.""" - - name: ClassVar[str] = "filter_dm" - description: ClassVar[str] = "A boolean field. If True, the filter can trigger for messages sent to the bot in DMs." - - filter_dm: bool - - def triggers_on(self, ctx: FilterContext) -> bool: - """Return whether the filter should be triggered even if it was triggered in DMs.""" - return hasattr(ctx.channel, "guild") or self.filter_dm diff --git a/bot/exts/filtering/_settings_types/infraction_and_notification.py b/bot/exts/filtering/_settings_types/infraction_and_notification.py deleted file mode 100644 index 4fcf2aa65..000000000 --- a/bot/exts/filtering/_settings_types/infraction_and_notification.py +++ /dev/null @@ -1,137 +0,0 @@ -from datetime import timedelta -from enum import Enum, auto -from typing import ClassVar - -import arrow -from discord import Colour, Embed -from discord.errors import Forbidden -from pydantic import validator - -import bot -from bot.constants import Channels, Guild -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ActionEntry - - -class Infraction(Enum): - """An enumeration of infraction types. The lower the value, the higher it is on the hierarchy.""" - - BAN = auto() - KICK = auto() - MUTE = auto() - VOICE_MUTE = auto() - SUPERSTAR = auto() - WARNING = auto() - WATCH = auto() - NOTE = auto() - - def __str__(self) -> str: - return self.name - - -class InfractionAndNotification(ActionEntry): - """ - A setting entry which specifies what infraction to issue and the notification to DM the user. - - Since a DM cannot be sent when a user is banned or kicked, these two functions need to be grouped together. - """ - - name: ClassVar[str] = "infraction_and_notification" - description: ClassVar[dict[str, str]] = { - "infraction_type": ( - "The type of infraction to issue when the filter triggers, or 'NONE'. " - "If two infractions are triggered for the same message, " - "the harsher one will be applied (by type or duration). " - "Superstars will be triggered even if there is a harsher infraction.\n\n" - "Valid infraction types in order of harshness: " - ) + ", ".join(infraction.name for infraction in Infraction), - "infraction_duration": "How long the infraction should last for in seconds, or 'None' for permanent.", - "infraction_reason": "The reason delivered with the infraction.", - "dm_content": "The contents of a message to be DMed to the offending user.", - "dm_embed": "The contents of the embed to be DMed to the offending user." - } - - dm_content: str | None - dm_embed: str | None - infraction_type: Infraction | None - infraction_reason: str | None - infraction_duration: float | None - - @validator("infraction_type", pre=True) - @classmethod - def convert_infraction_name(cls, infr_type: str) -> Infraction: - """Convert the string to an Infraction by name.""" - return Infraction[infr_type.replace(" ", "_").upper()] if infr_type else None - - async def action(self, ctx: FilterContext) -> None: - """Send the notification to the user, and apply any specified infractions.""" - # If there is no infraction to apply, any DM contents already provided in the context take precedence. - if self.infraction_type is None and (ctx.dm_content or ctx.dm_embed): - dm_content = ctx.dm_content - dm_embed = ctx.dm_embed - else: - dm_content = self.dm_content - dm_embed = self.dm_embed - - if dm_content or dm_embed: - formatting = {"domain": ctx.notification_domain} - dm_content = f"Hey {ctx.author.mention}!\n{dm_content.format(**formatting)}" - if dm_embed: - dm_embed = Embed(description=dm_embed.format(**formatting), colour=Colour.og_blurple()) - else: - dm_embed = None - - try: - await ctx.author.send(dm_content, embed=dm_embed) - ctx.action_descriptions.append("notified") - except Forbidden: - ctx.action_descriptions.append("notified (failed)") - - msg_ctx = await bot.instance.get_context(ctx.message) - msg_ctx.guild = bot.instance.get_guild(Guild.id) - msg_ctx.author = ctx.author - msg_ctx.channel = ctx.channel - - if self.infraction_type is not None: - if self.infraction_type == Infraction.BAN or not hasattr(ctx.channel, "guild"): - msg_ctx.channel = bot.instance.get_channel(Channels.mod_alerts) - msg_ctx.command = bot.instance.get_command(self.infraction_type.name.lower()) - await msg_ctx.invoke( - msg_ctx.command, - ctx.author, - arrow.utcnow() + timedelta(seconds=self.infraction_duration) - if self.infraction_duration is not None else None, - reason=self.infraction_reason - ) - ctx.action_descriptions.append(self.infraction_type.name.lower()) - - def __or__(self, other: ActionEntry): - """ - Combines two actions of the same type. Each type of action is executed once per filter. - - If the infractions are different, take the data of the one higher up the hierarchy. - - There is no clear way to properly combine several notification messages, especially when it's in two parts. - To avoid bombarding the user with several notifications, the message with the more significant infraction - is used. - """ - if not isinstance(other, InfractionAndNotification): - return NotImplemented - - # Lower number -> higher in the hierarchy - if self.infraction_type is None: - return other.copy() - elif other.infraction_type is None: - return self.copy() - elif self.infraction_type.value < other.infraction_type.value: - return self.copy() - elif self.infraction_type.value > other.infraction_type.value: - return other.copy() - else: - if self.infraction_duration is None or ( - other.infraction_duration is not None and self.infraction_duration > other.infraction_duration - ): - result = self.copy() - else: - result = other.copy() - return result diff --git a/bot/exts/filtering/_settings_types/ping.py b/bot/exts/filtering/_settings_types/ping.py deleted file mode 100644 index 0bfc12809..000000000 --- a/bot/exts/filtering/_settings_types/ping.py +++ /dev/null @@ -1,70 +0,0 @@ -from functools import cache -from typing import ClassVar - -from discord import Guild -from pydantic import validator - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ActionEntry - - -class Ping(ActionEntry): - """A setting entry which adds the appropriate pings to the alert.""" - - name: ClassVar[str] = "mentions" - description: ClassVar[dict[str, str]] = { - "guild_pings": ( - "A list of role IDs/role names/user IDs/user names/here/everyone. " - "If a mod-alert is generated for a filter triggered in a public channel, these will be pinged." - ), - "dm_pings": ( - "A list of role IDs/role names/user IDs/user names/here/everyone. " - "If a mod-alert is generated for a filter triggered in DMs, these will be pinged." - ) - } - - guild_pings: set[str] - dm_pings: set[str] - - @validator("*") - @classmethod - def init_sequence_if_none(cls, pings: list[str]) -> list[str]: - """Initialize an empty sequence if the value is None.""" - if pings is None: - return [] - return pings - - 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 - new_content = " ".join([self._resolve_mention(mention, ctx.channel.guild) for mention in mentions]) - ctx.alert_content = f"{new_content} {ctx.alert_content}" - - 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, Ping): - return NotImplemented - - return Ping(guild_pings=self.guild_pings | other.guild_pings, dm_pings=self.dm_pings | other.dm_pings) - - @staticmethod - @cache - def _resolve_mention(mention: str, guild: Guild) -> str: - """Return the appropriate formatting for the formatting, be it a literal, a user ID, or a role ID.""" - if mention in ("here", "everyone"): - return f"@{mention}" - if mention.isdigit(): # It's an ID. - mention = int(mention) - if any(mention == role.id for role in guild.roles): - return f"<@&{mention}>" - else: - return f"<@{mention}>" - - # It's a name - for role in guild.roles: - if role.name == mention: - return role.mention - for member in guild.members: - if str(member) == mention: - return member.mention - return mention diff --git a/bot/exts/filtering/_settings_types/send_alert.py b/bot/exts/filtering/_settings_types/send_alert.py deleted file mode 100644 index 04e400764..000000000 --- a/bot/exts/filtering/_settings_types/send_alert.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import ClassVar - -from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings_types.settings_entry import ActionEntry - - -class SendAlert(ActionEntry): - """A setting entry which tells whether to send an alert message.""" - - name: ClassVar[str] = "send_alert" - description: ClassVar[str] = "A boolean. If all filters triggered set this to False, no mod-alert will be created." - - send_alert: bool - - async def action(self, ctx: FilterContext) -> None: - """Add the stored pings to the alert message content.""" - ctx.send_alert = self.send_alert - - 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, SendAlert): - return NotImplemented - - return SendAlert(send_alert=self.send_alert or other.send_alert) diff --git a/bot/exts/filtering/_settings_types/validations/__init__.py b/bot/exts/filtering/_settings_types/validations/__init__.py new file mode 100644 index 000000000..5c44e8b27 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/__init__.py @@ -0,0 +1,8 @@ +from os.path import dirname + +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry +from bot.exts.filtering._utils import subclasses_in_package + +validation_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ValidationEntry) + +__all__ = [validation_types] diff --git a/bot/exts/filtering/_settings_types/validations/bypass_roles.py b/bot/exts/filtering/_settings_types/validations/bypass_roles.py new file mode 100644 index 000000000..a5c18cffc --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/bypass_roles.py @@ -0,0 +1,33 @@ +from typing import ClassVar, Union + +from discord import Member +from pydantic import validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class RoleBypass(ValidationEntry): + """A setting entry which tells whether the roles the member has allow them to bypass the filter.""" + + name: ClassVar[str] = "bypass_roles" + description: ClassVar[str] = "A list of role IDs or role names. Users with these roles will not trigger the filter." + + bypass_roles: set[Union[int, str]] + + @validator("bypass_roles", each_item=True) + @classmethod + def maybe_cast_to_int(cls, role: str) -> Union[int, str]: + """If the string is alphanumeric, cast it to int.""" + if role.isdigit(): + return int(role) + return role + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter should be triggered on this user given their roles.""" + if not isinstance(ctx.author, Member): + return True + return all( + member_role.id not in self.bypass_roles and member_role.name not in self.bypass_roles + for member_role in ctx.author.roles + ) diff --git a/bot/exts/filtering/_settings_types/validations/channel_scope.py b/bot/exts/filtering/_settings_types/validations/channel_scope.py new file mode 100644 index 000000000..fd5206b81 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/channel_scope.py @@ -0,0 +1,66 @@ +from typing import ClassVar, Union + +from pydantic import validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class ChannelScope(ValidationEntry): + """A setting entry which tells whether the filter was invoked in a whitelisted channel or category.""" + + name: ClassVar[str] = "channel_scope" + description: ClassVar[str] = { + "disabled_channels": "A list of channel IDs or channel names. The filter will not trigger in these channels.", + "disabled_categories": ( + "A list of category IDs or category names. The filter will not trigger in these categories." + ), + "enabled_channels": ( + "A list of channel IDs or channel names. " + "The filter can trigger in these channels even if the category is disabled." + ) + } + + disabled_channels: set[Union[str, int]] + disabled_categories: set[Union[str, int]] + enabled_channels: set[Union[str, int]] + + @validator("*", pre=True) + @classmethod + def init_if_sequence_none(cls, sequence: list[str]) -> list[str]: + """Initialize an empty sequence if the value is None.""" + if sequence is None: + return [] + return sequence + + @validator("*", each_item=True) + @classmethod + def maybe_cast_items(cls, channel_or_category: str) -> Union[str, int]: + """Cast to int each value in each sequence if it is alphanumeric.""" + if channel_or_category.isdigit(): + return int(channel_or_category) + return channel_or_category + + def triggers_on(self, ctx: FilterContext) -> bool: + """ + Return whether the filter should be triggered in the given channel. + + The filter is invoked by default. + If the channel is explicitly enabled, it bypasses the set disabled channels and categories. + """ + channel = ctx.channel + enabled_id = ( + channel.id in self.enabled_channels + or ( + channel.id not in self.disabled_channels + and (not channel.category or channel.category.id not in self.disabled_categories) + ) + ) + enabled_name = ( + channel.name in self.enabled_channels + or ( + channel.name not in self.disabled_channels + and (not channel.category or channel.category.name not in self.disabled_categories) + ) + ) + return enabled_id and enabled_name diff --git a/bot/exts/filtering/_settings_types/validations/enabled.py b/bot/exts/filtering/_settings_types/validations/enabled.py new file mode 100644 index 000000000..3b5e3e446 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/enabled.py @@ -0,0 +1,19 @@ +from typing import ClassVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class Enabled(ValidationEntry): + """A setting entry which tells whether the filter is enabled.""" + + name: ClassVar[str] = "enabled" + description: ClassVar[str] = ( + "A boolean field. Setting it to False allows disabling the filter without deleting it entirely." + ) + + enabled: bool + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter is enabled.""" + return self.enabled diff --git a/bot/exts/filtering/_settings_types/validations/filter_dm.py b/bot/exts/filtering/_settings_types/validations/filter_dm.py new file mode 100644 index 000000000..93022320f --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/filter_dm.py @@ -0,0 +1,17 @@ +from typing import ClassVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class FilterDM(ValidationEntry): + """A setting entry which tells whether to apply the filter to DMs.""" + + name: ClassVar[str] = "filter_dm" + description: ClassVar[str] = "A boolean field. If True, the filter can trigger for messages sent to the bot in DMs." + + filter_dm: bool + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter should be triggered even if it was triggered in DMs.""" + return hasattr(ctx.channel, "guild") or self.filter_dm diff --git a/tests/bot/exts/filtering/test_settings_entries.py b/tests/bot/exts/filtering/test_settings_entries.py index d18861bd6..8dba5cb26 100644 --- a/tests/bot/exts/filtering/test_settings_entries.py +++ b/tests/bot/exts/filtering/test_settings_entries.py @@ -1,12 +1,12 @@ import unittest from bot.exts.filtering._filter_context import Event, FilterContext -from bot.exts.filtering._settings_types.bypass_roles import RoleBypass -from bot.exts.filtering._settings_types.channel_scope import ChannelScope -from bot.exts.filtering._settings_types.filter_dm import FilterDM -from bot.exts.filtering._settings_types.infraction_and_notification import ( +from bot.exts.filtering._settings_types.actions.infraction_and_notification import ( Infraction, InfractionAndNotification, superstar ) +from bot.exts.filtering._settings_types.validations.bypass_roles import RoleBypass +from bot.exts.filtering._settings_types.validations.channel_scope import ChannelScope +from bot.exts.filtering._settings_types.validations.filter_dm import FilterDM from tests.helpers import MockCategoryChannel, MockDMChannel, MockMember, MockMessage, MockRole, MockTextChannel -- cgit v1.2.3