aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/filtering/_filter_lists/domain.py18
-rw-r--r--bot/exts/filtering/_filter_lists/extension.py19
-rw-r--r--bot/exts/filtering/_filter_lists/filter_list.py5
-rw-r--r--bot/exts/filtering/_filter_lists/invite.py22
-rw-r--r--bot/exts/filtering/_filter_lists/token.py19
-rw-r--r--bot/exts/filtering/_filters/domain.py19
-rw-r--r--bot/exts/filtering/_filters/extension.py8
-rw-r--r--bot/exts/filtering/_filters/filter.py13
-rw-r--r--bot/exts/filtering/_filters/invite.py8
-rw-r--r--bot/exts/filtering/_filters/token.py6
-rw-r--r--bot/exts/filtering/_settings_types/bypass_roles.py1
-rw-r--r--bot/exts/filtering/_settings_types/channel_scope.py10
-rw-r--r--bot/exts/filtering/_settings_types/delete_messages.py1
-rw-r--r--bot/exts/filtering/_settings_types/enabled.py1
-rw-r--r--bot/exts/filtering/_settings_types/filter_dm.py1
-rw-r--r--bot/exts/filtering/_settings_types/infraction_and_notification.py13
-rw-r--r--bot/exts/filtering/_settings_types/ping.py10
-rw-r--r--bot/exts/filtering/_settings_types/send_alert.py2
-rw-r--r--bot/exts/filtering/_settings_types/settings_entry.py3
-rw-r--r--bot/exts/filtering/filtering.py130
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