aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar mbaruh <[email protected]>2022-10-11 10:29:57 +0300
committerGravatar mbaruh <[email protected]>2022-10-11 10:29:57 +0300
commitfb4117bcee3028f3679ecfd99399f3b089c2beb9 (patch)
tree2ff3f4ccad7e85821111346687d862d97b8c21e9
parentAdd filter list edit command (diff)
Filter list add command
-rw-r--r--bot/exts/filtering/_ui/filter_list.py103
-rw-r--r--bot/exts/filtering/_ui/ui.py11
-rw-r--r--bot/exts/filtering/_utils.py21
-rw-r--r--bot/exts/filtering/filtering.py79
4 files changed, 189 insertions, 25 deletions
diff --git a/bot/exts/filtering/_ui/filter_list.py b/bot/exts/filtering/_ui/filter_list.py
index 051521f1e..cc58f5a62 100644
--- a/bot/exts/filtering/_ui/filter_list.py
+++ b/bot/exts/filtering/_ui/filter_list.py
@@ -61,8 +61,109 @@ def build_filterlist_repr_dict(filter_list: FilterList, list_type: ListType, new
return total_values
+class FilterListAddView(EditBaseView):
+ """A view used to add a new filter list."""
+
+ def __init__(
+ self,
+ list_name: str,
+ list_type: ListType,
+ settings: dict,
+ loaded_settings: dict,
+ author: User,
+ embed: Embed,
+ confirm_callback: Callable
+ ):
+ super().__init__(author)
+ self.list_name = list_name
+ self.list_type = list_type
+ self.settings = settings
+ self.loaded_settings = loaded_settings
+ self.embed = embed
+ self.confirm_callback = confirm_callback
+
+ self.settings_repr_dict = {name: to_serializable(value) for name, value in settings.items()}
+ populate_embed_from_dict(embed, self.settings_repr_dict)
+
+ self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()}
+
+ edit_select = CustomCallbackSelect(
+ self._prompt_new_value,
+ placeholder="Select a setting to edit",
+ options=[SelectOption(label=name) for name in sorted(settings)],
+ row=0
+ )
+ self.add_item(edit_select)
+
+ @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=1)
+ async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None:
+ """Confirm the content, description, and settings, and update the filters database."""
+ await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first.
+ try:
+ await self.confirm_callback(interaction.message, self.list_name, self.list_type, self.settings)
+ except ResponseCodeError as e:
+ await interaction.message.reply(embed=format_response_error(e))
+ await interaction.message.edit(view=self)
+ else:
+ self.stop()
+
+ @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red, row=1)
+ async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None:
+ """Cancel the operation."""
+ await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None)
+ self.stop()
+
+ def current_value(self, setting_name: str) -> Any:
+ """Get the current value stored for the setting or MISSING if none found."""
+ if setting_name in self.settings:
+ return self.settings[setting_name]
+ return MISSING
+
+ async def update_embed(
+ self,
+ interaction_or_msg: discord.Interaction | discord.Message,
+ *,
+ setting_name: str | None = None,
+ setting_value: str | None = None,
+ ) -> None:
+ """
+ Update the embed with the new information.
+
+ If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function.
+ """
+ if not setting_name: # Obligatory check to match the signature in the parent class.
+ return
+
+ self.settings[setting_name] = setting_value
+
+ self.embed.clear_fields()
+ new_view = self.copy()
+
+ try:
+ if isinstance(interaction_or_msg, discord.Interaction):
+ await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view)
+ else:
+ await interaction_or_msg.edit(embed=self.embed, view=new_view)
+ except discord.errors.HTTPException: # Various errors such as embed description being too long.
+ pass
+ else:
+ self.stop()
+
+ def copy(self) -> FilterListAddView:
+ """Create a copy of this view."""
+ return FilterListAddView(
+ self.list_name,
+ self.list_type,
+ self.settings,
+ self.loaded_settings,
+ self.author,
+ self.embed,
+ self.confirm_callback
+ )
+
+
class FilterListEditView(EditBaseView):
- """A view used to edit a filter's settings before updating the database."""
+ """A view used to edit a filter list's settings before updating the database."""
def __init__(
self,
diff --git a/bot/exts/filtering/_ui/ui.py b/bot/exts/filtering/_ui/ui.py
index dc3bd01c9..0ded6d4a4 100644
--- a/bot/exts/filtering/_ui/ui.py
+++ b/bot/exts/filtering/_ui/ui.py
@@ -266,18 +266,17 @@ class SequenceEditView(discord.ui.View):
"""Send the submitted value to be added to the list."""
await self.view.apply_edit(interaction, self.new_value.value)
- def __init__(self, setting_name: str, starting_value: list, type_: type, update_callback: Callable):
+ def __init__(self, setting_name: str, starting_value: list, update_callback: Callable):
super().__init__(timeout=COMPONENT_TIMEOUT)
self.setting_name = setting_name
self.stored_value = starting_value
- self.type_ = type_
self.update_callback = update_callback
- options = [SelectOption(label=item) for item in starting_value[:MAX_SELECT_ITEMS]]
+ options = [SelectOption(label=item) for item in self.stored_value[:MAX_SELECT_ITEMS]]
self.removal_select = CustomCallbackSelect(
self.apply_removal, placeholder="Enter an item to remove", options=options, row=1
)
- if starting_value:
+ if self.stored_value:
self.add_item(self.removal_select)
async def apply_removal(self, interaction: Interaction, select: discord.ui.Select) -> None:
@@ -391,12 +390,12 @@ class EditBaseView(ABC, discord.ui.View):
view = BooleanSelectView(setting_name, update_callback)
await interaction.response.send_message(f"Choose a value for `{setting_name}`:", view=view, ephemeral=True)
elif type_ in (set, list, tuple):
- current_list = self.current_value(setting_name)
+ current_list = list(self.current_value(setting_name))
if current_list is MISSING:
current_list = []
await interaction.response.send_message(
f"Current list: {current_list}",
- view=SequenceEditView(setting_name, current_list, type_, update_callback),
+ view=SequenceEditView(setting_name, current_list, update_callback),
ephemeral=True
)
elif isinstance(type_, EnumMeta):
diff --git a/bot/exts/filtering/_utils.py b/bot/exts/filtering/_utils.py
index 438c22d41..7149f7254 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 Any, Iterable, Union
+from typing import Any, Iterable, TypeVar, Union
import regex
@@ -13,6 +13,9 @@ 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)
+T = TypeVar('T')
+
+
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()
@@ -82,6 +85,22 @@ def repr_equals(override: Any, default: Any) -> bool:
return str(override) == str(default)
+def starting_value(type_: type[T]) -> T:
+ """Return a value of the given type."""
+ if hasattr(type_, "__origin__"):
+ if type_.__origin__ is not Union: # In case this is a types.GenericAlias or a typing._GenericAlias
+ type_ = type_.__origin__
+ if hasattr(type_, "__args__"): # In case of a Union
+ if type(None) in type_.__args__:
+ return None
+ type_ = type_.__args__[0] # Pick one, doesn't matter
+
+ try:
+ return type_()
+ except TypeError: # In case it all fails, return a string and let the user handle it.
+ return ""
+
+
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 a906b6a41..09f458a59 100644
--- a/bot/exts/filtering/filtering.py
+++ b/bot/exts/filtering/filtering.py
@@ -23,9 +23,11 @@ from bot.exts.filtering._settings import ActionSettings
from bot.exts.filtering._ui.filter import (
build_filter_repr_dict, description_and_settings_converter, filter_overrides, populate_embed_from_dict
)
-from bot.exts.filtering._ui.filter_list import DeleteConfirmationView, FilterListEditView, settings_converter
+from bot.exts.filtering._ui.filter_list import (
+ DeleteConfirmationView, FilterListAddView, FilterListEditView, settings_converter
+)
from bot.exts.filtering._ui.ui import ArgumentCompletionView
-from bot.exts.filtering._utils import past_tense, to_serializable
+from bot.exts.filtering._utils import past_tense, starting_value, to_serializable
from bot.log import get_logger
from bot.pagination import LinePaginator
from bot.utils.messages import format_channel, format_user
@@ -36,6 +38,9 @@ log = get_logger(__name__)
class Filtering(Cog):
"""Filtering and alerting for content posted on the server."""
+ # A set of filter list names with missing implementations that already caused a warning.
+ already_warned = set()
+
# region: init
def __init__(self, bot: Bot):
@@ -55,21 +60,10 @@ class Filtering(Cog):
Additionally, fetch the alerting webhook.
"""
await self.bot.wait_until_guild_available()
- already_warned = set()
raw_filter_lists = await self.bot.api_client.get("bot/filter/filter_lists")
for raw_filter_list in raw_filter_lists:
- list_name = raw_filter_list["name"]
- if list_name not in self.filter_lists:
- if list_name not in filter_list_types:
- if list_name not in already_warned:
- log.warning(
- f"A filter list named {list_name} was loaded from the database, but no matching class."
- )
- already_warned.add(list_name)
- continue
- self.filter_lists[list_name] = filter_list_types[list_name](self)
- self.filter_lists[list_name].add_list(raw_filter_list)
+ self._load_raw_filter_list(raw_filter_list)
try:
self.webhook = await self.bot.fetch_webhook(Webhooks.filters)
@@ -520,10 +514,36 @@ class Filtering(Cog):
# 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"
+ name=f"Description of the {past_tense(list_type.name.lower())} {list_name.lower()} filter list"
)
await ctx.send(embed=embed)
+ @filterlist.command(name="add", aliases=("a",))
+ @has_any_role(Roles.admins)
+ async def fl_add(self, ctx: Context, list_type: list_type_converter, list_name: str) -> None:
+ """Add a new filter list."""
+ list_description = f"{past_tense(list_type.name.lower())} {list_name.lower()}"
+ if list_name in self.filter_lists:
+ filter_list = self.filter_lists[list_name]
+ if list_type in filter_list.filter_lists:
+ await ctx.reply(f":x: The {list_description} filter list already exists.")
+ return
+
+ embed = Embed(colour=Colour.blue())
+ embed.set_author(name=f"New Filter List - {list_description.title()}")
+ settings = {name: starting_value(value[2]) for name, value in self.loaded_settings.items()}
+
+ view = FilterListAddView(
+ list_name,
+ list_type,
+ settings,
+ self.loaded_settings,
+ ctx.author,
+ embed,
+ self._post_filter_list
+ )
+ await ctx.send(embed=embed, reference=ctx.message, view=view)
+
@filterlist.command(name="edit", aliases=("e",))
@has_any_role(Roles.admins)
async def fl_edit(
@@ -600,6 +620,20 @@ class Filtering(Cog):
# endregion
# region: helper functions
+ def _load_raw_filter_list(self, list_data: dict) -> None:
+ """Load the raw list data to the cog."""
+ list_name = list_data["name"]
+ if list_name not in self.filter_lists:
+ if list_name not in filter_list_types:
+ if list_name not in self.already_warned:
+ log.warning(
+ f"A filter list named {list_name} was loaded from the database, but no matching class."
+ )
+ self.already_warned.add(list_name)
+ return
+ self.filter_lists[list_name] = filter_list_types[list_name](self)
+ self.filter_lists[list_name].add_list(list_data)
+
async def _resolve_action(self, ctx: FilterContext) -> tuple[Optional[ActionSettings], dict[FilterList, str]]:
"""
Return the actions that should be taken for all filter lists in the given context.
@@ -844,16 +878,27 @@ class Filtering(Cog):
payload = {
"content": content, "description": description, "additional_field": json.dumps(filter_settings), **settings
}
- response = await bot.instance.api_client.patch(f'bot/filter/filters/{filter_.id}', json=payload)
+ response = await bot.instance.api_client.patch(
+ f'bot/filter/filters/{filter_.id}', json=to_serializable(payload)
+ )
edited_filter = filter_list.add_filter(response, list_type)
extra_msg = Filtering._identical_filters_message(content, filter_list, list_type, edited_filter)
await msg.reply(f"✅ Edited filter: {edited_filter}" + extra_msg)
+ async def _post_filter_list(self, msg: Message, list_name: str, list_type: ListType, settings: dict) -> None:
+ """POST the new data of the filter list to the site API."""
+ payload = {"name": list_name, "list_type": list_type.value, **to_serializable(settings)}
+ response = await bot.instance.api_client.post('bot/filter/filter_lists', json=payload)
+ self._load_raw_filter_list(response)
+ await msg.reply(f"✅ Added a new filter list: {past_tense(list_type.name.lower())} {list_name}")
+
@staticmethod
async def _patch_filter_list(msg: Message, filter_list: FilterList, list_type: ListType, settings: dict) -> None:
"""PATCH the new data of the filter list to the site API."""
list_id = filter_list.list_ids[list_type]
- response = await bot.instance.api_client.patch(f'bot/filter/filter_lists/{list_id}', json=settings)
+ response = await bot.instance.api_client.patch(
+ f'bot/filter/filter_lists/{list_id}', json=to_serializable(settings)
+ )
filter_list.remove_list(list_type)
filter_list.add_list(response)
await msg.reply(f"✅ Edited filter list: {past_tense(list_type.name.lower())} {filter_list.name}")