diff options
20 files changed, 287 insertions, 22 deletions
diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py index c48164369..05c520ce2 100644 --- a/bot/exts/filtering/_filter_lists/domain.py +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -4,11 +4,12 @@ import re import typing from functools import reduce from operator import or_ -from typing import Optional +from typing import Optional, Type from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType from bot.exts.filtering._filters.domain import DomainFilter +from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._settings import ActionSettings from bot.exts.filtering._utils import clean_input @@ -19,7 +20,15 @@ URL_RE = re.compile(r"https?://([^\s]+)", flags=re.IGNORECASE) class DomainsList(FilterList): - """A list of filters, each looking for a specific domain given by URL.""" + """ + A list of filters, each looking for a specific domain given by URL. + + The blacklist defaults dictate what happens by default when a filter is matched, and can be overridden by + individual filters. + + Domains are found by looking for a URL schema (http or https). + Filters will also trigger for subdomains unless set otherwise. + """ name = "domain" @@ -27,6 +36,11 @@ class DomainsList(FilterList): super().__init__(DomainFilter) filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT) + @property + def filter_types(self) -> set[Type[Filter]]: + """Return the types of filters used by this list.""" + return {DomainFilter} + async def actions_for(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], Optional[str]]: """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" text = ctx.content diff --git a/bot/exts/filtering/_filter_lists/extension.py b/bot/exts/filtering/_filter_lists/extension.py index ceb8bb958..b70ab6772 100644 --- a/bot/exts/filtering/_filter_lists/extension.py +++ b/bot/exts/filtering/_filter_lists/extension.py @@ -2,13 +2,14 @@ from __future__ import annotations import typing from os.path import splitext -from typing import Optional +from typing import Optional, Type import bot from bot.constants import Channels, URLs from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType from bot.exts.filtering._filters.extension import ExtensionFilter +from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._settings import ActionSettings if typing.TYPE_CHECKING: @@ -34,7 +35,16 @@ DISALLOWED_EMBED_DESCRIPTION = ( class ExtensionsList(FilterList): - """A list of filters, each looking for an attachment with a specific extension.""" + """ + A list of filters, each looking for a file attachment with a specific extension. + + If an extension is not explicitly allowed, it will be blocked. + + Whitelist defaults dictate what happens when an extension is *not* explicitly allowed, + and whitelist filters overrides have no effect. + + Items should be added as file extensions preceded by a dot. + """ name = "extension" @@ -43,6 +53,11 @@ class ExtensionsList(FilterList): filtering_cog.subscribe(self, Event.MESSAGE) self._whitelisted_description = None + @property + def filter_types(self) -> set[Type[Filter]]: + """Return the types of filters used by this list.""" + return {ExtensionFilter} + async def actions_for(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], Optional[str]]: """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" # Return early if the message doesn't have attachments. diff --git a/bot/exts/filtering/_filter_lists/filter_list.py b/bot/exts/filtering/_filter_lists/filter_list.py index 5fc992597..60c884a04 100644 --- a/bot/exts/filtering/_filter_lists/filter_list.py +++ b/bot/exts/filtering/_filter_lists/filter_list.py @@ -63,6 +63,11 @@ class FilterList(FieldRequiring): log.warning(e) self.filter_lists[list_type] = filters + @property + @abstractmethod + def filter_types(self) -> set[Type[Filter]]: + """Return the types of filters used by this list.""" + @abstractmethod async def actions_for(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], Optional[str]]: """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py index cadd82d0c..c79cd9b51 100644 --- a/bot/exts/filtering/_filter_lists/invite.py +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -3,7 +3,7 @@ from __future__ import annotations import typing from functools import reduce from operator import or_ -from typing import Optional +from typing import Optional, Type from botcore.utils.regex import DISCORD_INVITE from discord import Embed, Invite @@ -12,6 +12,7 @@ from discord.errors import NotFound import bot from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._filters.invite import InviteFilter from bot.exts.filtering._settings import ActionSettings from bot.exts.filtering._utils import clean_input @@ -21,7 +22,19 @@ if typing.TYPE_CHECKING: class InviteList(FilterList): - """A list of filters, each looking for guild invites to a specific guild.""" + """ + A list of filters, each looking for guild invites to a specific guild. + + If the invite is not whitelisted, it will be blocked. Partnered and verified servers are allowed unless blacklisted. + + Whitelist defaults dictate what happens when an invite is *not* explicitly allowed, + and whitelist filters overrides have no effect. + + Blacklist defaults dictate what happens by default when an explicitly blocked invite is found. + + Items in the list are added through invites for the purpose of fetching the guild info. + Items are stored as guild IDs, guild invites are *not* stored. + """ name = "invite" @@ -29,6 +42,11 @@ class InviteList(FilterList): super().__init__(InviteFilter) filtering_cog.subscribe(self, Event.MESSAGE) + @property + def filter_types(self) -> set[Type[Filter]]: + """Return the types of filters used by this list.""" + return {InviteFilter} + async def actions_for(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], Optional[str]]: """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" _, failed = self.defaults[ListType.ALLOW]["validations"].evaluate(ctx) diff --git a/bot/exts/filtering/_filter_lists/token.py b/bot/exts/filtering/_filter_lists/token.py index c232b55e5..5be3fd0e8 100644 --- a/bot/exts/filtering/_filter_lists/token.py +++ b/bot/exts/filtering/_filter_lists/token.py @@ -4,10 +4,11 @@ import re import typing from functools import reduce from operator import or_ -from typing import Optional +from typing import Optional, Type from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._filters.token import TokenFilter from bot.exts.filtering._settings import ActionSettings from bot.exts.filtering._utils import clean_input @@ -19,7 +20,16 @@ SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) class TokensList(FilterList): - """A list of filters, each looking for a specific token given by regex.""" + """ + A list of filters, each looking for a specific token in the given content given as regex. + + The blacklist defaults dictate what happens by default when a filter is matched, and can be overridden by + individual filters. + + Usually, if blocking literal strings, the literals themselves can be specified as the filter's value. + But since this is a list of regex patterns, be careful of the items added. For example, a dot needs to be escaped + to function as a literal dot. + """ name = "token" @@ -27,6 +37,11 @@ class TokensList(FilterList): super().__init__(TokenFilter) filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT) + @property + def filter_types(self) -> set[Type[Filter]]: + """Return the types of filters used by this list.""" + return {TokenFilter} + async def actions_for(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], Optional[str]]: """Dispatch the given event to the list's filters, and return actions to take and a message to relay to mods.""" text = ctx.content diff --git a/bot/exts/filtering/_filters/domain.py b/bot/exts/filtering/_filters/domain.py index 1511d6a5c..9f5f97413 100644 --- a/bot/exts/filtering/_filters/domain.py +++ b/bot/exts/filtering/_filters/domain.py @@ -1,26 +1,33 @@ -from typing import Optional +from typing import ClassVar, Optional import tldextract from pydantic import BaseModel from bot.exts.filtering._filter_context import FilterContext from bot.exts.filtering._filters.filter import Filter -from bot.exts.filtering._settings import ActionSettings class ExtraDomainSettings(BaseModel): """Extra settings for how domains should be matched in a message.""" + exact_description: ClassVar[str] = ( + "A boolean. If True, will match the filter content exactly, and won't trigger for subdomains and subpaths." + ) + # whether to match the filter content exactly, or to trigger for subdomains and subpaths as well. exact: Optional[bool] = False class DomainFilter(Filter): - """A filter which looks for a specific domain given by URL.""" + """ + A filter which looks for a specific domain given by URL. + + The schema (http, https) does not need to be included in the filter. + Will also match subdomains unless set otherwise. + """ - def __init__(self, filter_data: dict, action_defaults: Optional[ActionSettings] = None): - super().__init__(filter_data, action_defaults) - self.extra_fields = ExtraDomainSettings.parse_raw(self.extra_fields) + name = "domain" + extra_fields_type = ExtraDomainSettings def triggered_on(self, ctx: FilterContext) -> bool: """Searches for a domain within a given context.""" diff --git a/bot/exts/filtering/_filters/extension.py b/bot/exts/filtering/_filters/extension.py index 85bfd05b2..1a2ab8617 100644 --- a/bot/exts/filtering/_filters/extension.py +++ b/bot/exts/filtering/_filters/extension.py @@ -3,7 +3,13 @@ from bot.exts.filtering._filters.filter import Filter class ExtensionFilter(Filter): - """A filter which looks for a specific attachment extension in messages.""" + """ + A filter which looks for a specific attachment extension in messages. + + The filter stores the extension preceded by a dot. + """ + + name = "extension" def triggered_on(self, ctx: FilterContext) -> bool: """Searches for an attachment extension in the context content, given as a set of extensions.""" diff --git a/bot/exts/filtering/_filters/filter.py b/bot/exts/filtering/_filters/filter.py index b4beb8386..d27b3dae3 100644 --- a/bot/exts/filtering/_filters/filter.py +++ b/bot/exts/filtering/_filters/filter.py @@ -1,11 +1,12 @@ -from abc import ABC, abstractmethod +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._utils import FieldRequiring -class Filter(ABC): +class Filter(FieldRequiring): """ A class representing a filter. @@ -13,6 +14,12 @@ class Filter(ABC): and defines what action should be performed if it is triggered. """ + # Each subclass must define a name which will be used to fetch its description. + # Names must be unique across all types of filters. + name = FieldRequiring.MUST_SET_UNIQUE + # 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): self.id = filter_data["id"] self.content = filter_data["content"] @@ -23,6 +30,8 @@ class Filter(ABC): 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) @abstractmethod def triggered_on(self, ctx: FilterContext) -> bool: diff --git a/bot/exts/filtering/_filters/invite.py b/bot/exts/filtering/_filters/invite.py index afe4fdd94..e5b68258c 100644 --- a/bot/exts/filtering/_filters/invite.py +++ b/bot/exts/filtering/_filters/invite.py @@ -6,7 +6,13 @@ from bot.exts.filtering._settings import ActionSettings class InviteFilter(Filter): - """A filter which looks for invites to a specific guild in messages.""" + """ + A filter which looks for invites to a specific guild in messages. + + The filter stores the guild ID which is allowed or denied. + """ + + name = "invite" def __init__(self, filter_data: dict, action_defaults: Optional[ActionSettings] = None): super().__init__(filter_data, action_defaults) diff --git a/bot/exts/filtering/_filters/token.py b/bot/exts/filtering/_filters/token.py index 07590c54b..c955b269b 100644 --- a/bot/exts/filtering/_filters/token.py +++ b/bot/exts/filtering/_filters/token.py @@ -1,12 +1,14 @@ import re -from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filters.filter import Filter class TokenFilter(Filter): """A filter which looks for a specific token given by regex.""" + name = "token" + def triggered_on(self, ctx: FilterContext) -> bool: """Searches for a regex pattern within a given context.""" pattern = self.content @@ -16,5 +18,3 @@ class TokenFilter(Filter): ctx.matches.append(match[0]) return True return False - - diff --git a/bot/exts/filtering/_settings_types/bypass_roles.py b/bot/exts/filtering/_settings_types/bypass_roles.py index bfc4a30fd..290ea53c1 100644 --- a/bot/exts/filtering/_settings_types/bypass_roles.py +++ b/bot/exts/filtering/_settings_types/bypass_roles.py @@ -10,6 +10,7 @@ class RoleBypass(ValidationEntry): """A setting entry which tells whether the roles the member has allow them to bypass the filter.""" name = "bypass_roles" + description = "A list of role IDs or role names. Users with these roles will not trigger the filter." def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/channel_scope.py b/bot/exts/filtering/_settings_types/channel_scope.py index 63da6c7e5..3a95834b3 100644 --- a/bot/exts/filtering/_settings_types/channel_scope.py +++ b/bot/exts/filtering/_settings_types/channel_scope.py @@ -15,6 +15,16 @@ class ChannelScope(ValidationEntry): """A setting entry which tells whether the filter was invoked in a whitelisted channel or category.""" name = "channel_scope" + description = { + "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." + ) + } def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/delete_messages.py b/bot/exts/filtering/_settings_types/delete_messages.py index ad715f04c..8de58f804 100644 --- a/bot/exts/filtering/_settings_types/delete_messages.py +++ b/bot/exts/filtering/_settings_types/delete_messages.py @@ -11,6 +11,7 @@ class DeleteMessages(ActionEntry): """A setting entry which tells whether to delete the offending message(s).""" name = "delete_messages" + description = "A boolean field. If True, the filter being triggered will cause the offending message to be deleted." def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/enabled.py b/bot/exts/filtering/_settings_types/enabled.py index 553dccc9c..081ae02b0 100644 --- a/bot/exts/filtering/_settings_types/enabled.py +++ b/bot/exts/filtering/_settings_types/enabled.py @@ -8,6 +8,7 @@ class Enabled(ValidationEntry): """A setting entry which tells whether the filter is enabled.""" name = "enabled" + description = "A boolean field. Setting it to False allows disabling the filter without deleting it entirely." def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/filter_dm.py b/bot/exts/filtering/_settings_types/filter_dm.py index 54f19e4d1..676e04aa9 100644 --- a/bot/exts/filtering/_settings_types/filter_dm.py +++ b/bot/exts/filtering/_settings_types/filter_dm.py @@ -8,6 +8,7 @@ class FilterDM(ValidationEntry): """A setting entry which tells whether to apply the filter to DMs.""" name = "filter_dm" + description = "A boolean field. If True, the filter can trigger for messages sent to the bot in DMs." def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/infraction_and_notification.py b/bot/exts/filtering/_settings_types/infraction_and_notification.py index 82e2ff6d6..03574049a 100644 --- a/bot/exts/filtering/_settings_types/infraction_and_notification.py +++ b/bot/exts/filtering/_settings_types/infraction_and_notification.py @@ -46,6 +46,19 @@ class InfractionAndNotification(ActionEntry): """ name = "infraction_and_notification" + description = { + "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." + } def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/ping.py b/bot/exts/filtering/_settings_types/ping.py index 0f9a014c4..9d3bef562 100644 --- a/bot/exts/filtering/_settings_types/ping.py +++ b/bot/exts/filtering/_settings_types/ping.py @@ -11,6 +11,16 @@ class Ping(ActionEntry): """A setting entry which adds the appropriate pings to the alert.""" name = "mentions" + description = { + "ping_type": ( + "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": ( + "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." + ) + } def __init__(self, entry_data: Any): super().__init__(entry_data) diff --git a/bot/exts/filtering/_settings_types/send_alert.py b/bot/exts/filtering/_settings_types/send_alert.py index e332494eb..6429b99ac 100644 --- a/bot/exts/filtering/_settings_types/send_alert.py +++ b/bot/exts/filtering/_settings_types/send_alert.py @@ -8,6 +8,7 @@ class SendAlert(ActionEntry): """A setting entry which tells whether to send an alert message.""" name = "send_alert" + description = "A boolean field. If all filters triggered set this to False, no mod-alert will be created." def __init__(self, entry_data: Any): super().__init__(entry_data) @@ -23,4 +24,3 @@ class SendAlert(ActionEntry): return NotImplemented return SendAlert(self.send_alert or other.send_alert) - diff --git a/bot/exts/filtering/_settings_types/settings_entry.py b/bot/exts/filtering/_settings_types/settings_entry.py index b0d54fac3..c3a1a8a07 100644 --- a/bot/exts/filtering/_settings_types/settings_entry.py +++ b/bot/exts/filtering/_settings_types/settings_entry.py @@ -17,6 +17,9 @@ class SettingsEntry(FieldRequiring): # Each subclass must define a name matching the entry name we're expecting to receive from the database. # Names must be unique across all filter lists. name = FieldRequiring.MUST_SET_UNIQUE + # Each subclass must define a description of what it does. If the data an entry type receives is comprised of + # several DB fields, the value should a dictionary of field names and their descriptions. + description = FieldRequiring.MUST_SET @abstractmethod def __init__(self, entry_data: Any): diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 2e5cca5fa..2cfb45656 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -1,4 +1,5 @@ import operator +import re from collections import defaultdict from functools import reduce from typing import Optional @@ -33,6 +34,10 @@ class Filtering(Cog): self._subscriptions: defaultdict[Event, list[FilterList]] = defaultdict(list) self.webhook = None + self.loaded_settings = {} + self.loaded_filters = {} + self.loaded_filter_settings = {} + async def cog_load(self) -> None: """ Fetch the filter data from the API, parse it, and load it to the appropriate data structures. @@ -61,6 +66,8 @@ class Filtering(Cog): except HTTPException: log.error(f"Failed to fetch incidents webhook with ID `{Webhooks.incidents}`.") + self.collect_loaded_types() + def subscribe(self, filter_list: FilterList, *events: Event) -> None: """ Subscribe a filter list to the given events. @@ -78,6 +85,49 @@ class Filtering(Cog): if filter_list not in self._subscriptions[event]: self._subscriptions[event].append(filter_list) + def collect_loaded_types(self) -> None: + """ + Go over the classes used in initialization and collect them to dictionaries. + + The information that is collected is about the types actually used to load the API response, not all types + available in the filtering extension. + """ + # Get the filter types used by each filter list. + for filter_list in self.filter_lists.values(): + self.loaded_filters.update({filter_type.name: filter_type for filter_type in filter_list.filter_types}) + + # Get the setting types used by each filter list. + if self.filter_lists: + # Any filter list has the fields for all settings in the DB schema, so picking any one of them is enough. + list_defaults = list(list(self.filter_lists.values())[0].defaults.values())[0] + settings_types = set() + # The settings are split between actions and validations. + settings_types.update(type(setting) for _, setting in list_defaults["actions"].items()) + settings_types.update(type(setting) for _, setting in list_defaults["validations"].items()) + for setting_type in settings_types: + # The description should be either a string or a dictionary. + if isinstance(setting_type.description, str): + # If it's a string, then the setting matches a single field in the DB, + # and its name is the setting type's name attribute. + self.loaded_settings[setting_type.name] = setting_type.description, setting_type + else: + # Otherwise, the setting type works with compound settings. + self.loaded_settings.update({ + subsetting: (description, setting_type) + for subsetting, description in setting_type.description.items() + }) + + # Get the settings per filter as well. + for filter_name, filter_type in self.loaded_filters.items(): + extra_fields_type = filter_type.extra_fields_type + if not extra_fields_type: + continue + # A class var with a `_description` suffix is expected per field name. + self.loaded_filter_settings[filter_name] = { + field_name: (getattr(extra_fields_type, f"{field_name}_description", ""), extra_fields_type) + for field_name in extra_fields_type.__fields__ + } + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return await has_any_role(*MODERATION_ROLES).predicate(ctx) @@ -174,6 +224,86 @@ class Filtering(Cog): await self._send_list(ctx, list_name, list_type) + @filter.command(name="describe", aliases=("explain", "manual")) + async def f_describe(self, ctx: Context, filter_name: Optional[str]) -> None: + """Show a description of the specified filter, or a list of possible values if no name is specified.""" + if not filter_name: + embed = Embed(description="\n".join(self.loaded_filters)) + embed.set_author(name="List of filter names") + else: + filter_type = self.loaded_filters.get(filter_name) + if not filter_type: + filter_type = self.loaded_filters.get(filter_name[:-1]) # A plural form or a typo. + if not filter_type: + await ctx.send(f":x: There's no filter type named {filter_name!r}.") + return + # Use the class's docstring, and ignore single newlines. + embed = Embed(description=re.sub(r"(?<!\n)\n(?!\n)", " ", filter_type.__doc__)) + embed.set_author(name=f"Description of the {filter_name} filter") + embed.colour = Colour.blue() + await ctx.send(embed=embed) + + @filter.group(aliases=("settings",)) + async def setting(self, ctx: Context) -> None: + """Group for settings-related commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @setting.command(name="describe", aliases=("explain", "manual")) + async def s_describe(self, ctx: Context, setting_name: Optional[str]) -> None: + """Show a description of the specified setting, or a list of possible settings if no name is specified.""" + if not setting_name: + settings_list = list(self.loaded_settings) + for filter_name, filter_settings in self.loaded_filter_settings.items(): + settings_list.extend(f"{filter_name}/{setting}" for setting in filter_settings) + embed = Embed(description="\n".join(settings_list)) + embed.set_author(name="List of setting names") + else: + # The setting is either in a SettingsEntry subclass, or a pydantic model. + setting_data = self.loaded_settings.get(setting_name) + description = None + if setting_data: + description = setting_data[0] + elif "/" in setting_name: # It's a filter specific setting. + filter_name, filter_setting_name = setting_name.split("/", maxsplit=1) + if filter_name in self.loaded_filter_settings: + if filter_setting_name in self.loaded_filter_settings[filter_name]: + description = self.loaded_filter_settings[filter_name][filter_setting_name][0] + if description is None: + await ctx.send(f":x: There's no setting type named {setting_name!r}.") + return + embed = Embed(description=description) + embed.set_author(name=f"Description of the {setting_name} setting") + embed.colour = Colour.blue() + await ctx.send(embed=embed) + + # endregion + # region: filterlist group + + @commands.group(aliases=("fl",)) + async def filterlist(self, ctx: Context) -> None: + """Group for managing filter lists.""" + 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)) + 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) + # endregion # region: helper functions |