diff options
author | 2022-03-05 18:38:47 +0200 | |
---|---|---|
committer | 2022-07-16 02:32:37 +0300 | |
commit | 9670acce01bc81ca608aa8881f9394577b81ca99 (patch) | |
tree | 69a6ebf046130c743bb2952300b32f4ab52ae831 | |
parent | Add filter-type-specific settings (diff) |
Add system description commands
The system is fairly complex, and to avoid having to delve into the source code, this commit adds several commands to show descriptions for various components of the system:
- Filters
- Filter lists
- Settings (both from the DB schema and filter-type-specific)
The names that can be described are the ones which are actually being used in the system. So if no tokens list was loaded, it can't be described even if there is an implementation for it.
If no name is relayed to the commands, the list of options will be shown instead.
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 |