diff options
author | 2022-03-08 20:51:06 +0200 | |
---|---|---|
committer | 2022-07-16 02:34:04 +0300 | |
commit | 0c2f567ae0d917b8ace8cfd370cecb1c43c46472 (patch) | |
tree | 797ecca762a9b1ff45c2fdbd71c9ea6607b8b453 | |
parent | Add system description commands (diff) |
Add settings display for individual filters and filter lists
The filterlist describe command is changed to include the default settings it contains.
The filters group can now take a filter ID, and it will display embed detailing the filter data and settings. The mechanic is similar to how individual infractions can be displayed with `!infraction <id>`.
- To be able to quickly find whether the filter with the provided ID is in a specific list, the data structure was changed to a dictionary.
- To be able to mark which settings have the default values and which are overrides, resolving the full actions of a filter is deferred to when the filter is actually triggered. This wasn't possible in the beginning of development, but now that each filterlist can resolve the action to be taken with its own internal logic, it is.
- Some attribute names of SettingsEntry subclasses were changed to match the name under which they're stored in the DB. This allows displaying the settings with the names that need to be used to edit them.
- Each filterlist now contains all settings, even if they're empty, so that they can be displayed. While this is slightly less efficient, I considered it too negligible to make displaying the settings messier than it already is.
- Some additional refactoring in the cog was done to avoid code repetition.
-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 |