diff options
-rw-r--r-- | bot/exts/filtering/_filter_lists/domain.py | 8 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/extension.py | 4 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/filter_list.py | 18 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/invite.py | 14 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/token.py | 8 | ||||
-rw-r--r-- | bot/exts/filtering/_filters/filter.py | 9 | ||||
-rw-r--r-- | bot/exts/filtering/_filters/invite.py | 7 | ||||
-rw-r--r-- | bot/exts/filtering/_settings.py | 33 | ||||
-rw-r--r-- | bot/exts/filtering/_settings_types/bypass_roles.py | 8 | ||||
-rw-r--r-- | bot/exts/filtering/_settings_types/filter_dm.py | 4 | ||||
-rw-r--r-- | bot/exts/filtering/_settings_types/infraction_and_notification.py | 5 | ||||
-rw-r--r-- | bot/exts/filtering/_settings_types/ping.py | 15 | ||||
-rw-r--r-- | bot/exts/filtering/_settings_types/settings_entry.py | 4 | ||||
-rw-r--r-- | bot/exts/filtering/_utils.py | 20 | ||||
-rw-r--r-- | bot/exts/filtering/filtering.py | 199 |
15 files changed, 248 insertions, 108 deletions
diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py index 05c520ce2..7f92b62e8 100644 --- a/bot/exts/filtering/_filter_lists/domain.py +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -58,7 +58,13 @@ class DomainsList(FilterList): actions = None message = "" if triggers: - actions = reduce(or_, (filter_.actions for filter_ in triggers)) + action_defaults = self.defaults[ListType.DENY]["actions"] + actions = reduce( + or_, + (filter_.actions.fallback_to(action_defaults) if filter_.actions else action_defaults + for filter_ in triggers + ) + ) if len(triggers) == 1: message = f"#{triggers[0].id} (`{triggers[0].content}`)" if triggers[0].description: diff --git a/bot/exts/filtering/_filter_lists/extension.py b/bot/exts/filtering/_filter_lists/extension.py index b70ab6772..2447bebde 100644 --- a/bot/exts/filtering/_filter_lists/extension.py +++ b/bot/exts/filtering/_filter_lists/extension.py @@ -73,7 +73,7 @@ class ExtensionsList(FilterList): (splitext(attachment.filename.lower())[1], attachment.filename) for attachment in ctx.message.attachments } new_ctx = ctx.replace(content={ext for ext, _ in all_ext}) # And prepare the context for the filters to read. - triggered = [filter_ for filter_ in self.filter_lists[ListType.ALLOW] if filter_.triggered_on(new_ctx)] + triggered = [filter_ for filter_ in self.filter_lists[ListType.ALLOW].values() if filter_.triggered_on(new_ctx)] allowed_ext = {filter_.content for filter_ in triggered} # Get the extensions in the message that are allowed. # See if there are any extensions left which aren't allowed. @@ -97,7 +97,7 @@ class ExtensionsList(FilterList): meta_channel = bot.instance.get_channel(Channels.meta) if not self._whitelisted_description: self._whitelisted_description = ', '.join( - filter_.content for filter_ in self.filter_lists[ListType.ALLOW] + filter_.content for filter_ in self.filter_lists[ListType.ALLOW].values() ) ctx.dm_embed = DISALLOWED_EMBED_DESCRIPTION.format( joined_whitelist=self._whitelisted_description, diff --git a/bot/exts/filtering/_filter_lists/filter_list.py b/bot/exts/filtering/_filter_lists/filter_list.py index 60c884a04..3b5138fe4 100644 --- a/bot/exts/filtering/_filter_lists/filter_list.py +++ b/bot/exts/filtering/_filter_lists/filter_list.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import Dict, List, Optional, Type +from typing import Optional, Type from discord.ext.commands import BadArgument @@ -44,21 +44,21 @@ class FilterList(FieldRequiring): name = FieldRequiring.MUST_SET_UNIQUE def __init__(self, filter_type: Type[Filter]): - self.filter_lists: dict[ListType, list[Filter]] = {} + self.filter_lists: dict[ListType, dict[int, Filter]] = {} self.defaults = {} self.filter_type = filter_type - def add_list(self, list_data: Dict) -> None: + def add_list(self, list_data: dict) -> None: """Add a new type of list (such as a whitelist or a blacklist) this filter list.""" - actions, validations = create_settings(list_data["settings"]) + actions, validations = create_settings(list_data["settings"], keep_empty=True) list_type = ListType(list_data["list_type"]) self.defaults[list_type] = {"actions": actions, "validations": validations} - filters = [] + filters = {} for filter_data in list_data["filters"]: try: - filters.append(self.filter_type(filter_data, actions)) + filters[filter_data["id"]] = self.filter_type(filter_data) except TypeError as e: log.warning(e) self.filter_lists[list_type] = filters @@ -73,7 +73,9 @@ class FilterList(FieldRequiring): """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" @staticmethod - def filter_list_result(ctx: FilterContext, filters: List[Filter], defaults: ValidationSettings) -> list[Filter]: + def filter_list_result( + ctx: FilterContext, filters: dict[int, Filter], defaults: ValidationSettings + ) -> list[Filter]: """ Sift through the list of filters, and return only the ones which apply to the given context. @@ -91,7 +93,7 @@ class FilterList(FieldRequiring): default_answer = not bool(failed_by_default) relevant_filters = [] - for filter_ in filters: + for filter_ in filters.values(): if not filter_.validations: if default_answer and filter_.triggered_on(ctx): relevant_filters.append(filter_) diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py index c79cd9b51..4e8d74d8a 100644 --- a/bot/exts/filtering/_filter_lists/invite.py +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -84,7 +84,9 @@ class InviteList(FilterList): # Add the disallowed by default unless they're whitelisted. guilds_for_inspection = {invite.guild.id for invite in denied_by_default.values()} new_ctx = ctx.replace(content=guilds_for_inspection) - allowed = {filter_.content for filter_ in self.filter_lists[ListType.ALLOW] if filter_.triggered_on(new_ctx)} + allowed = { + filter_.content for filter_ in self.filter_lists[ListType.ALLOW].values() if filter_.triggered_on(new_ctx) + } disallowed_invites.update({ invite_code: invite for invite_code, invite in denied_by_default.items() if invite.guild.id not in allowed }) @@ -105,7 +107,15 @@ class InviteList(FilterList): actions = None if len(disallowed_invites) > len(triggered): # There are invites which weren't allowed but aren't blacklisted. - actions = reduce(or_, (filter_.actions for filter_ in triggered), self.defaults[ListType.ALLOW]["actions"]) + deny_defaults = self.defaults[ListType.DENY]["actions"] + actions = reduce( + or_, + ( + filter_.actions.fallback_to(deny_defaults) if filter_.actions else deny_defaults + for filter_ in triggered + ), + self.defaults[ListType.ALLOW]["actions"] + ) elif triggered: actions = reduce(or_, (filter_.actions for filter_ in triggered)) ctx.matches += {match[0] for match in matches if match.group("invite") in disallowed_invites} diff --git a/bot/exts/filtering/_filter_lists/token.py b/bot/exts/filtering/_filter_lists/token.py index 5be3fd0e8..c989b06b9 100644 --- a/bot/exts/filtering/_filter_lists/token.py +++ b/bot/exts/filtering/_filter_lists/token.py @@ -58,7 +58,13 @@ class TokensList(FilterList): actions = None message = "" if triggers: - actions = reduce(or_, (filter_.actions for filter_ in triggers)) + action_defaults = self.defaults[ListType.DENY]["actions"] + actions = reduce( + or_, + (filter_.actions.fallback_to(action_defaults) if filter_.actions else action_defaults + for filter_ in triggers + ) + ) if len(triggers) == 1: message = f"#{triggers[0].id} (`{triggers[0].content}`)" if triggers[0].description: diff --git a/bot/exts/filtering/_filters/filter.py b/bot/exts/filtering/_filters/filter.py index d27b3dae3..da149dce6 100644 --- a/bot/exts/filtering/_filters/filter.py +++ b/bot/exts/filtering/_filters/filter.py @@ -1,8 +1,7 @@ from abc import abstractmethod -from typing import Optional from bot.exts.filtering._filter_context import FilterContext -from bot.exts.filtering._settings import ActionSettings, create_settings +from bot.exts.filtering._settings import create_settings from bot.exts.filtering._utils import FieldRequiring @@ -20,15 +19,11 @@ class Filter(FieldRequiring): # If a subclass uses extra fields, it should assign the pydantic model type to this variable. extra_fields_type = None - def __init__(self, filter_data: dict, action_defaults: Optional[ActionSettings] = None): + def __init__(self, filter_data: dict): self.id = filter_data["id"] self.content = filter_data["content"] self.description = filter_data["description"] self.actions, self.validations = create_settings(filter_data["settings"]) - if not self.actions: - self.actions = action_defaults - elif action_defaults: - self.actions.fallback_to(action_defaults) self.extra_fields = filter_data["additional_field"] or "{}" # noqa: P103 if self.extra_fields_type: self.extra_fields = self.extra_fields_type.parse_raw(self.extra_fields) diff --git a/bot/exts/filtering/_filters/invite.py b/bot/exts/filtering/_filters/invite.py index e5b68258c..5a9924833 100644 --- a/bot/exts/filtering/_filters/invite.py +++ b/bot/exts/filtering/_filters/invite.py @@ -1,8 +1,5 @@ -from typing import Optional - from bot.exts.filtering._filter_context import FilterContext from bot.exts.filtering._filters.filter import Filter -from bot.exts.filtering._settings import ActionSettings class InviteFilter(Filter): @@ -14,8 +11,8 @@ class InviteFilter(Filter): name = "invite" - def __init__(self, filter_data: dict, action_defaults: Optional[ActionSettings] = None): - super().__init__(filter_data, action_defaults) + def __init__(self, filter_data: dict): + super().__init__(filter_data) self.content = int(self.content) def triggered_on(self, ctx: FilterContext) -> bool: diff --git a/bot/exts/filtering/_settings.py b/bot/exts/filtering/_settings.py index b53400b78..f88b26ee3 100644 --- a/bot/exts/filtering/_settings.py +++ b/bot/exts/filtering/_settings.py @@ -16,7 +16,9 @@ log = get_logger(__name__) _already_warned: set[str] = set() -def create_settings(settings_data: dict) -> tuple[Optional[ActionSettings], Optional[ValidationSettings]]: +def create_settings( + settings_data: dict, *, keep_empty: bool = False +) -> tuple[Optional[ActionSettings], Optional[ValidationSettings]]: """ Create and return instances of the Settings subclasses from the given data. @@ -34,7 +36,10 @@ def create_settings(settings_data: dict) -> tuple[Optional[ActionSettings], Opti f"A setting named {entry_name} was loaded from the database, but no matching class." ) _already_warned.add(entry_name) - return ActionSettings.create(action_data), ValidationSettings.create(validation_data) + return ( + ActionSettings.create(action_data, keep_empty=keep_empty), + ValidationSettings.create(validation_data, keep_empty=keep_empty) + ) class Settings(FieldRequiring): @@ -54,7 +59,7 @@ class Settings(FieldRequiring): _already_warned: set[str] = set() @abstractmethod - def __init__(self, settings_data: dict): + def __init__(self, settings_data: dict, *, keep_empty: bool = False): self._entries: dict[str, Settings.entry_type] = {} entry_classes = settings_types.get(self.entry_type.__name__) @@ -70,7 +75,7 @@ class Settings(FieldRequiring): self._already_warned.add(entry_name) else: try: - new_entry = entry_cls.create(entry_data) + new_entry = entry_cls.create(entry_data, keep_empty=keep_empty) if new_entry: self._entries[entry_name] = new_entry except TypeError as e: @@ -103,17 +108,17 @@ class Settings(FieldRequiring): return self._entries.get(key, default) @classmethod - def create(cls, settings_data: dict) -> Optional[Settings]: + def create(cls, settings_data: dict, *, keep_empty: bool = False) -> Optional[Settings]: """ Returns a Settings object from `settings_data` if it holds any value, None otherwise. Use this method to create Settings objects instead of the init. The None value is significant for how a filter list iterates over its filters. """ - settings = cls(settings_data) + settings = cls(settings_data, keep_empty=keep_empty) # If an entry doesn't hold any values, its `create` method will return None. # If all entries are None, then the settings object holds no values. - if not any(settings._entries.values()): + if not keep_empty and not any(settings._entries.values()): return None return settings @@ -129,8 +134,8 @@ class ValidationSettings(Settings): entry_type = ValidationEntry - def __init__(self, settings_data: dict): - super().__init__(settings_data) + def __init__(self, settings_data: dict, *, keep_empty: bool = False): + super().__init__(settings_data, keep_empty=keep_empty) def evaluate(self, ctx: FilterContext) -> tuple[set[str], set[str]]: """Evaluates for each setting whether the context is relevant to the filter.""" @@ -158,8 +163,8 @@ class ActionSettings(Settings): entry_type = ActionEntry - def __init__(self, settings_data: dict): - super().__init__(settings_data) + def __init__(self, settings_data: dict, *, keep_empty: bool = False): + super().__init__(settings_data, keep_empty=keep_empty) def __or__(self, other: ActionSettings) -> ActionSettings: """Combine the entries of two collections of settings into a new ActionsSettings.""" @@ -183,8 +188,10 @@ class ActionSettings(Settings): for entry in self._entries.values(): await entry.action(ctx) - def fallback_to(self, fallback: ActionSettings) -> None: + def fallback_to(self, fallback: ActionSettings) -> ActionSettings: """Fill in missing entries from `fallback`.""" + new_actions = self.copy() for entry_name, entry_value in fallback.items(): if entry_name not in self._entries: - self._entries[entry_name] = entry_value + new_actions._entries[entry_name] = entry_value + return new_actions diff --git a/bot/exts/filtering/_settings_types/bypass_roles.py b/bot/exts/filtering/_settings_types/bypass_roles.py index 290ea53c1..e183e0b42 100644 --- a/bot/exts/filtering/_settings_types/bypass_roles.py +++ b/bot/exts/filtering/_settings_types/bypass_roles.py @@ -14,18 +14,18 @@ class RoleBypass(ValidationEntry): def __init__(self, entry_data: Any): super().__init__(entry_data) - self.roles = set() + self.bypass_roles = set() for role in entry_data: if role.isdigit(): - self.roles.add(int(role)) + self.bypass_roles.add(int(role)) else: - self.roles.add(role) + self.bypass_roles.add(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.roles and member_role.name not in self.roles + 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/filter_dm.py b/bot/exts/filtering/_settings_types/filter_dm.py index 676e04aa9..1405a636f 100644 --- a/bot/exts/filtering/_settings_types/filter_dm.py +++ b/bot/exts/filtering/_settings_types/filter_dm.py @@ -12,8 +12,8 @@ class FilterDM(ValidationEntry): def __init__(self, entry_data: Any): super().__init__(entry_data) - self.apply_in_dm = entry_data + self.filter_dm = entry_data 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.apply_in_dm + 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 index 03574049a..4fae09f23 100644 --- a/bot/exts/filtering/_settings_types/infraction_and_notification.py +++ b/bot/exts/filtering/_settings_types/infraction_and_notification.py @@ -34,6 +34,11 @@ class Infraction(Enum): """ return self != Infraction.NONE + def __str__(self) -> str: + if self == Infraction.NONE: + return "" + return self.name + superstar = namedtuple("superstar", ["reason", "duration"]) diff --git a/bot/exts/filtering/_settings_types/ping.py b/bot/exts/filtering/_settings_types/ping.py index 9d3bef562..1e0067690 100644 --- a/bot/exts/filtering/_settings_types/ping.py +++ b/bot/exts/filtering/_settings_types/ping.py @@ -12,11 +12,11 @@ class Ping(ActionEntry): name = "mentions" description = { - "ping_type": ( + "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_ping_type": ( + "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." ) @@ -24,12 +24,13 @@ class Ping(ActionEntry): def __init__(self, entry_data: Any): super().__init__(entry_data) - self.guild_mentions = set(entry_data["guild_pings"]) - self.dm_mentions = set(entry_data["dm_pings"]) + + self.guild_pings = set(entry_data["guild_pings"]) if entry_data["guild_pings"] else set() + self.dm_pings = set(entry_data["dm_pings"]) if entry_data["dm_pings"] else set() async def action(self, ctx: FilterContext) -> None: """Add the stored pings to the alert message content.""" - mentions = self.guild_mentions if ctx.channel.guild else self.dm_mentions + 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}" @@ -39,8 +40,8 @@ class Ping(ActionEntry): return NotImplemented return Ping({ - "ping_type": self.guild_mentions | other.guild_mentions, - "dm_ping_type": self.dm_mentions | other.dm_mentions + "ping_type": self.guild_pings | other.guild_pings, + "dm_ping_type": self.dm_pings | other.dm_pings }) @staticmethod diff --git a/bot/exts/filtering/_settings_types/settings_entry.py b/bot/exts/filtering/_settings_types/settings_entry.py index c3a1a8a07..2883deed8 100644 --- a/bot/exts/filtering/_settings_types/settings_entry.py +++ b/bot/exts/filtering/_settings_types/settings_entry.py @@ -46,7 +46,7 @@ class SettingsEntry(FieldRequiring): return self.__class__(self.to_dict()) @classmethod - def create(cls, entry_data: Optional[dict[str, Any]]) -> Optional[SettingsEntry]: + def create(cls, entry_data: Optional[dict[str, Any]], *, keep_empty: bool = False) -> Optional[SettingsEntry]: """ Returns a SettingsEntry object from `entry_data` if it holds any value, None otherwise. @@ -55,7 +55,7 @@ class SettingsEntry(FieldRequiring): """ if entry_data is None: return None - if hasattr(entry_data, "values") and not any(value for value in entry_data.values()): + if not keep_empty and hasattr(entry_data, "values") and not any(value for value in entry_data.values()): return None return cls(entry_data) diff --git a/bot/exts/filtering/_utils.py b/bot/exts/filtering/_utils.py index d09262193..14c6bd13b 100644 --- a/bot/exts/filtering/_utils.py +++ b/bot/exts/filtering/_utils.py @@ -4,7 +4,7 @@ import inspect import pkgutil from abc import ABC, abstractmethod from collections import defaultdict -from typing import Set +from typing import Any, Iterable, Union import regex @@ -13,7 +13,7 @@ INVISIBLE_RE = regex.compile(rf"[{VARIATION_SELECTORS}\p{{UNASSIGNED}}\p{{FORMAT ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIATION_SELECTORS}]]", regex.V1) -def subclasses_in_package(package: str, prefix: str, parent: type) -> Set[type]: +def subclasses_in_package(package: str, prefix: str, parent: type) -> set[type]: """Return all the subclasses of class `parent`, found in the top-level of `package`, given by absolute path.""" subclasses = set() @@ -50,6 +50,22 @@ def past_tense(word: str) -> str: return word + "ed" +def to_serializable(item: Any) -> Union[bool, int, float, str, list, dict, None]: + """Convert the item into an object that can be converted to JSON.""" + if isinstance(item, (bool, int, float, str, type(None))): + return item + if isinstance(item, dict): + result = {} + for key, value in item.items(): + if not isinstance(key, (bool, int, float, str, type(None))): + key = str(key) + result[key] = to_serializable(value) + return result + if isinstance(item, Iterable): + return [to_serializable(subitem) for subitem in item] + return str(item) + + class FieldRequiring(ABC): """A mixin class that can force its concrete subclasses to set a value for specific class attributes.""" diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 2cfb45656..2a24769d0 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -13,15 +13,21 @@ from bot.bot import Bot from bot.constants import Colours, MODERATION_ROLES, Webhooks from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists import FilterList, ListType, filter_list_types, list_type_converter +from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._settings import ActionSettings from bot.exts.filtering._ui import ArgumentCompletionView -from bot.exts.filtering._utils import past_tense +from bot.exts.filtering._utils import past_tense, to_serializable from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.messages import format_channel, format_user log = get_logger(__name__) +# Max number of characters in a Discord embed field value, minus 6 characters for a placeholder. +MAX_FIELD_SIZE = 1018 +# Max number of characters for an embed field's value before it should take its own line. +MAX_INLINE_SIZE = 50 + class Filtering(Cog): """Filtering and alerting for content posted on the server.""" @@ -161,13 +167,11 @@ class Filtering(Cog): @blocklist.command(name="list", aliases=("get",)) async def bl_list(self, ctx: Context, list_name: Optional[str] = None) -> None: """List the contents of a specified blacklist.""" - if list_name is None: - await ctx.send( - "The **list_name** argument is unspecified. Please pick a value from the options below:", - view=ArgumentCompletionView(ctx, [], "list_name", list(self.filter_lists), 1, None) - ) + result = self._resolve_list_type_and_name(ctx, ListType.DENY, list_name) + if not result: return - await self._send_list(ctx, list_name, ListType.DENY) + list_type, filter_list = result + await self._send_list(ctx, filter_list, list_type) # endregion # region: whitelist commands @@ -181,48 +185,83 @@ class Filtering(Cog): @allowlist.command(name="list", aliases=("get",)) async def al_list(self, ctx: Context, list_name: Optional[str] = None) -> None: """List the contents of a specified whitelist.""" - if list_name is None: - await ctx.send( - "The **list_name** argument is unspecified. Please pick a value from the options below:", - view=ArgumentCompletionView(ctx, [], "list_name", list(self.filter_lists), 1, None) - ) + result = self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name) + if not result: return - await self._send_list(ctx, list_name, ListType.ALLOW) + list_type, filter_list = result + await self._send_list(ctx, filter_list, list_type) # endregion # region: filter commands - @commands.group(aliases=("filters", "f")) - async def filter(self, ctx: Context) -> None: - """Group for managing filters.""" - if not ctx.invoked_subcommand: + @commands.group(aliases=("filters", "f"), invoke_without_command=True) + async def filter(self, ctx: Context, id_: Optional[int] = None) -> None: + """ + Group for managing filters. + + If a valid filter ID is provided, an embed describing the filter will be posted. + """ + if not ctx.invoked_subcommand and not id_: await ctx.send_help(ctx.command) + return + + result = self._get_filter_by_id(id_) + if result is None: + await ctx.send(f":x: Could not find a filter with ID `{id_}`.") + return + filter_, filter_list, list_type = result + + # Get filter list settings + default_setting_values = {} + for type_ in ("actions", "validations"): + for _, setting in filter_list.defaults[list_type][type_].items(): + default_setting_values.update(to_serializable(setting.to_dict())) + + # Get the filter's overridden settings + overrides_values = {} + for settings in (filter_.actions, filter_.validations): + if settings: + for _, setting in settings.items(): + overrides_values.update(to_serializable(setting.to_dict())) + + # Combine them. It's done in this way to preserve field order, since the filter won't have all settings. + total_values = {} + for name, value in default_setting_values.items(): + if name not in overrides_values: + total_values[name] = value + else: + total_values[f"{name}*"] = overrides_values[name] + # Add the filter-specific settings. + if hasattr(filter_.extra_fields, "dict"): + extra_fields_overrides = filter_.extra_fields.dict(exclude_unset=True) + for name, value in filter_.extra_fields.dict().items(): + if name not in extra_fields_overrides: + total_values[f"{filter_.name}/{name}"] = value + else: + total_values[f"{filter_.name}/{name}*"] = value + + embed = self._build_embed_from_dict(total_values) + embed.description = f"`{filter_.content}`" + if filter_.description: + embed.description += f" - {filter_.description}" + embed.set_author(name=f"Filter #{id_} - " + f"{past_tense(list_type.name.lower())} {filter_list.name}".title()) + embed.set_footer(text=( + "Field names with an asterisk have values which override the defaults of the containing filter list. " + f"To view all defaults of the list, run `!filterlist describe {list_type.name} {filter_list.name}`." + )) + await ctx.send(embed=embed) @filter.command(name="list", aliases=("get",)) async def f_list( self, ctx: Context, list_type: Optional[list_type_converter] = None, list_name: Optional[str] = None ) -> None: """List the contents of a specified list of filters.""" - if list_name is None: - await ctx.send( - "The **list_name** argument is unspecified. Please pick a value from the options below:", - view=ArgumentCompletionView(ctx, [list_type], "list_name", list(self.filter_lists), 1, None) - ) + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: return + list_type, filter_list = result - if list_type is None: - filter_list = self._get_list_by_name(list_name) - if len(filter_list.filter_lists) > 1: - await ctx.send( - "The **list_type** argument is unspecified. Please pick a value from the options below:", - view=ArgumentCompletionView( - ctx, [list_name], "list_type", [option.name for option in ListType], 0, list_type_converter - ) - ) - return - list_type = list(filter_list.filter_lists)[0] - - await self._send_list(ctx, list_name, list_type) + await self._send_list(ctx, filter_list, list_type) @filter.command(name="describe", aliases=("explain", "manual")) async def f_describe(self, ctx: Context, filter_name: Optional[str]) -> None: @@ -286,22 +325,34 @@ class Filtering(Cog): if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) - @filterlist.command(name="describe", aliases=("explain", "manual")) - async def fl_describe(self, ctx: Context, filterlist_name: Optional[str]) -> None: - """Show a description of the specified filter list, or a list of possible values if no name is specified.""" - if not filterlist_name: - embed = Embed(description="\n".join(self.filter_lists)) + @filterlist.command(name="describe", aliases=("explain", "manual", "id")) + async def fl_describe( + self, ctx: Context, list_type: Optional[list_type_converter] = None, list_name: Optional[str] = None + ) -> None: + """Show a description of the specified filter list, or a list of possible values if no values are provided.""" + if not list_type and not list_name: + embed = Embed(description="\n".join(f"\u2003 {fl}" for fl in self.filter_lists), colour=Colour.blue()) embed.set_author(name="List of filter lists names") - else: - try: - filter_list = self._get_list_by_name(filterlist_name) - except BadArgument as e: - await ctx.send(f":x: {e}") - return - # Use the class's docstring, and ignore single newlines. - embed = Embed(description=re.sub(r"(?<!\n)\n(?!\n)", " ", filter_list.__doc__)) - embed.set_author(name=f"Description of the {filterlist_name} filter list") - embed.colour = Colour.blue() + await ctx.send(embed=embed) + return + + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + + list_defaults = filter_list.defaults[list_type] + setting_values = {} + for type_ in ("actions", "validations"): + for _, setting in list_defaults[type_].items(): + setting_values.update(to_serializable(setting.to_dict())) + + embed = self._build_embed_from_dict(setting_values) + # Use the class's docstring, and ignore single newlines. + embed.description = re.sub(r"(?<!\n)\n(?!\n)", " ", filter_list.__doc__) + embed.set_author( + name=f"Description of the {past_tense(list_type.name.lower())} {list_name.title()} filter list" + ) await ctx.send(embed=embed) # endregion @@ -363,6 +414,30 @@ class Filtering(Cog): await self.webhook.send(username=name, content=ctx.alert_content, embeds=[embed, *ctx.alert_embeds][:10]) + async def _resolve_list_type_and_name( + self, ctx: Context, list_type: Optional[ListType] = None, list_name: Optional[str] = None + ) -> Optional[tuple[ListType, FilterList]]: + """Prompt the user to complete the list type or list name if one of them is missing.""" + if list_name is None: + await ctx.send( + "The **list_name** argument is unspecified. Please pick a value from the options below:", + view=ArgumentCompletionView(ctx, [list_type], "list_name", list(self.filter_lists), 1, None) + ) + return None + + filter_list = self._get_list_by_name(list_name) + if list_type is None: + if len(filter_list.filter_lists) > 1: + await ctx.send( + "The **list_type** argument is unspecified. Please pick a value from the options below:", + view=ArgumentCompletionView( + ctx, [list_name], "list_type", [option.name for option in ListType], 0, list_type_converter + ) + ) + return None + list_type = list(filter_list.filter_lists)[0] + return list_type, filter_list + def _get_list_by_name(self, list_name: str) -> FilterList: """Get a filter list by its name, or raise an error if there's no such list.""" log.trace(f"Getting the filter list matching the name {list_name}") @@ -375,9 +450,9 @@ class Filtering(Cog): log.trace(f"Found list named {filter_list.name}") return filter_list - async def _send_list(self, ctx: Context, list_name: str, list_type: ListType) -> None: + @staticmethod + async def _send_list(ctx: Context, filter_list: FilterList, list_type: ListType) -> None: """Show the list of filters identified by the list name and type.""" - filter_list = self._get_list_by_name(list_name) type_filters = filter_list.filter_lists.get(list_type) if type_filters is None: await ctx.send(f":x: There is no list of {past_tense(list_type.name.lower())} {filter_list.name}s.") @@ -391,6 +466,26 @@ class Filtering(Cog): await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + def _get_filter_by_id(self, id_: int) -> Optional[tuple[Filter, FilterList, ListType]]: + """Get the filter object corresponding to the provided ID, along with its containing list and list type.""" + for filter_list in self.filter_lists.values(): + for list_type, sublist in filter_list.filter_lists.items(): + if id_ in sublist: + return sublist[id_], filter_list, list_type + + @staticmethod + def _build_embed_from_dict(data: dict) -> Embed: + """Build a Discord embed by populating fields from the given dict.""" + embed = Embed(description="", colour=Colour.blue()) + for setting, value in data.items(): + if setting.startswith("_"): + continue + value = str(value) if value not in ("", None) else "-" + if len(value) > MAX_FIELD_SIZE: + value = value[:MAX_FIELD_SIZE] + " [...]" + embed.add_field(name=setting, value=value, inline=len(value) < MAX_INLINE_SIZE) + return embed + # endregion |