aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/filtering/_filter_lists/domain.py8
-rw-r--r--bot/exts/filtering/_filter_lists/extension.py4
-rw-r--r--bot/exts/filtering/_filter_lists/filter_list.py18
-rw-r--r--bot/exts/filtering/_filter_lists/invite.py14
-rw-r--r--bot/exts/filtering/_filter_lists/token.py8
-rw-r--r--bot/exts/filtering/_filters/filter.py9
-rw-r--r--bot/exts/filtering/_filters/invite.py7
-rw-r--r--bot/exts/filtering/_settings.py33
-rw-r--r--bot/exts/filtering/_settings_types/bypass_roles.py8
-rw-r--r--bot/exts/filtering/_settings_types/filter_dm.py4
-rw-r--r--bot/exts/filtering/_settings_types/infraction_and_notification.py5
-rw-r--r--bot/exts/filtering/_settings_types/ping.py15
-rw-r--r--bot/exts/filtering/_settings_types/settings_entry.py4
-rw-r--r--bot/exts/filtering/_utils.py20
-rw-r--r--bot/exts/filtering/filtering.py199
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