diff options
| author | 2022-10-10 22:40:05 +0300 | |
|---|---|---|
| committer | 2022-10-10 22:40:05 +0300 | |
| commit | 02fedb2304b85c734dce8efc08d54106acdc8986 (patch) | |
| tree | 06aa9504c9cae5bbde980e44a93c1d68a74ccb50 | |
| parent | Rearrange UI into several modules (diff) | |
Add filter list edit command
The UI is a simplified version of the filter UI. In fact the two views now use the same base class.
Also fixes a bug in filters with displaying the correct value in the embed for filter settings.
| -rw-r--r-- | bot/exts/filtering/_filter_lists/domain.py | 2 | ||||
| -rw-r--r-- | bot/exts/filtering/_ui/filter.py | 102 | ||||
| -rw-r--r-- | bot/exts/filtering/_ui/filter_list.py | 166 | ||||
| -rw-r--r-- | bot/exts/filtering/_ui/ui.py | 97 | ||||
| -rw-r--r-- | bot/exts/filtering/filtering.py | 170 |
5 files changed, 393 insertions, 144 deletions
diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py index c407108ca..cae2eb878 100644 --- a/bot/exts/filtering/_filter_lists/domain.py +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -27,7 +27,7 @@ class DomainsList(FilterList): individual filters. Domains are found by looking for a URL schema (http or https). - Filters will also trigger for subdomains unless set otherwise. + Filters will also trigger for subdomains. """ name = "domain" diff --git a/bot/exts/filtering/_ui/filter.py b/bot/exts/filtering/_ui/filter.py index a26f5a841..4da8fe001 100644 --- a/bot/exts/filtering/_ui/filter.py +++ b/bot/exts/filtering/_ui/filter.py @@ -1,14 +1,10 @@ from __future__ import annotations -import re -from enum import EnumMeta -from functools import partial from typing import Any, Callable import discord import discord.ui from botcore.site_api import ResponseCodeError -from botcore.utils import scheduling from discord import Embed, Interaction, User from discord.ext.commands import BadArgument from discord.ui.select import SelectOption @@ -16,23 +12,14 @@ from discord.ui.select import SelectOption from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType from bot.exts.filtering._filters.filter import Filter from bot.exts.filtering._ui.ui import ( - BooleanSelectView, COMPONENT_TIMEOUT, CustomCallbackSelect, EnumSelectView, FreeInputModal, SequenceEditView, - format_response_error, parse_value, remove_optional + COMPONENT_TIMEOUT, CustomCallbackSelect, EditBaseView, MISSING, SETTINGS_DELIMITER, SINGLE_SETTING_PATTERN, + format_response_error, parse_value, populate_embed_from_dict ) from bot.exts.filtering._utils import repr_equals, to_serializable from bot.log import get_logger 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 -# Number of seconds before a settings editing view timeout. -EDIT_TIMEOUT = 600 -# Max length of modal text component label -MAX_MODAL_LABEL_LENGTH = 45 - def build_filter_repr_dict( filter_list: FilterList, @@ -63,30 +50,17 @@ def build_filter_repr_dict( if name not in extra_fields_overrides or repr_equals(extra_fields_overrides[name], value): total_values[f"{filter_type.name}/{name}"] = value else: - total_values[f"{filter_type.name}/{name}*"] = value + total_values[f"{filter_type.name}/{name}*"] = extra_fields_overrides[name] return total_values -def populate_embed_from_dict(embed: Embed, data: dict) -> None: - """Populate a Discord embed by populating fields from the given dict.""" - for setting, value in data.items(): - if setting.startswith("_"): - continue - if type(value) in (set, tuple): - value = list(value) - 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) - - class EditContentModal(discord.ui.Modal, title="Edit Content"): """A modal to input a filter's content.""" content = discord.ui.TextInput(label="Content") - def __init__(self, embed_view: SettingsEditView, message: discord.Message): + def __init__(self, embed_view: FilterEditView, message: discord.Message): super().__init__(timeout=COMPONENT_TIMEOUT) self.embed_view = embed_view self.message = message @@ -102,7 +76,7 @@ class EditDescriptionModal(discord.ui.Modal, title="Edit Description"): description = discord.ui.TextInput(label="Description") - def __init__(self, embed_view: SettingsEditView, message: discord.Message): + def __init__(self, embed_view: FilterEditView, message: discord.Message): super().__init__(timeout=COMPONENT_TIMEOUT) self.embed_view = embed_view self.message = message @@ -118,7 +92,7 @@ class TemplateModal(discord.ui.Modal, title="Template"): template = discord.ui.TextInput(label="Template Filter ID") - def __init__(self, embed_view: SettingsEditView, message: discord.Message): + def __init__(self, embed_view: FilterEditView, message: discord.Message): super().__init__(timeout=COMPONENT_TIMEOUT) self.embed_view = embed_view self.message = message @@ -128,7 +102,7 @@ class TemplateModal(discord.ui.Modal, title="Template"): await self.embed_view.apply_template(self.template.value, self.message, interaction) -class SettingsEditView(discord.ui.View): +class FilterEditView(EditBaseView): """A view used to edit a filter's settings before updating the database.""" class _REMOVE: @@ -149,7 +123,7 @@ class SettingsEditView(discord.ui.View): embed: Embed, confirm_callback: Callable ): - super().__init__(timeout=EDIT_TIMEOUT) + super().__init__(author) self.filter_list = filter_list self.list_type = list_type self.filter_type = filter_type @@ -159,7 +133,6 @@ class SettingsEditView(discord.ui.View): self.filter_settings_overrides = filter_settings_overrides self.loaded_settings = loaded_settings self.loaded_filter_settings = loaded_filter_settings - self.author = author self.embed = embed self.confirm_callback = confirm_callback @@ -175,7 +148,7 @@ class SettingsEditView(discord.ui.View): }) add_select = CustomCallbackSelect( - self._prompt_new_override, + self._prompt_new_value, placeholder="Select a setting to edit", options=[SelectOption(label=name) for name in sorted(self.type_per_setting_name)], row=1 @@ -194,10 +167,6 @@ class SettingsEditView(discord.ui.View): if remove_select.options: self.add_item(remove_select) - async def interaction_check(self, interaction: Interaction) -> bool: - """Only allow interactions from the command invoker.""" - return interaction.user.id == self.author.id - @discord.ui.button(label="Edit Content", row=3) async def edit_content(self, interaction: Interaction, button: discord.ui.Button) -> None: """A button to edit the filter's content. Pressing the button invokes a modal.""" @@ -259,44 +228,24 @@ class SettingsEditView(discord.ui.View): await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None) self.stop() - async def _prompt_new_override(self, interaction: Interaction, select: discord.ui.Select) -> None: - """Prompt the user to give an override value for the setting they selected, and respond to the interaction.""" - setting_name = select.values[0] - type_ = self.type_per_setting_name[setting_name] - is_optional, type_ = remove_optional(type_) - if hasattr(type_, "__origin__"): # In case this is a types.GenericAlias or a typing._GenericAlias - type_ = type_.__origin__ - new_view = self.copy() - # This is in order to not block the interaction response. There's a potential race condition here, since - # a view's method is used without guaranteeing the task completed, but since it depends on user input - # realistically it shouldn't happen. - scheduling.create_task(interaction.message.edit(view=new_view)) - update_callback = partial(new_view.update_embed, interaction_or_msg=interaction.message) - if type_ is bool: - 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_value = self.settings_overrides.get(setting_name, []) - await interaction.response.send_message( - f"Current list: {current_value}", - view=SequenceEditView(setting_name, current_value, type_, update_callback), - ephemeral=True - ) - elif isinstance(type_, EnumMeta): - view = EnumSelectView(setting_name, type_, update_callback) - await interaction.response.send_message(f"Choose a value for `{setting_name}`:", view=view, ephemeral=True) - else: - await interaction.response.send_modal(FreeInputModal(setting_name, not is_optional, type_, update_callback)) - 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_overrides: + return self.settings_overrides[setting_name] + if "/" in setting_name: + _, setting_name = setting_name.split("/", maxsplit=1) + if setting_name in self.filter_settings_overrides: + return self.filter_settings_overrides[setting_name] + return MISSING async def update_embed( self, interaction_or_msg: discord.Interaction | discord.Message, *, content: str | None = None, - description: str | type[SettingsEditView._REMOVE] | None = None, + description: str | type[FilterEditView._REMOVE] | None = None, setting_name: str | None = None, - setting_value: str | type[SettingsEditView._REMOVE] | None = None, + setting_value: str | type[FilterEditView._REMOVE] | None = None, ) -> None: """ Update the embed with the new information. @@ -386,9 +335,9 @@ class SettingsEditView(discord.ui.View): """ await self.update_embed(interaction, setting_name=select.values[0], setting_value=self._REMOVE) - def copy(self) -> SettingsEditView: + def copy(self) -> FilterEditView: """Create a copy of this view.""" - return SettingsEditView( + return FilterEditView( self.filter_list, self.list_type, self.filter_type, @@ -416,15 +365,12 @@ def description_and_settings_converter( if not input_data: return "", {}, {} - settings_pattern = re.compile(r"\s+(?=\S+=\S+)") - single_setting_pattern = re.compile(r"\w+=.+") - - parsed = settings_pattern.split(input_data) + parsed = SETTINGS_DELIMITER.split(input_data) if not parsed: return "", {}, {} description = "" - if not single_setting_pattern.match(parsed[0]): + if not SINGLE_SETTING_PATTERN.match(parsed[0]): description, *parsed = parsed settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} diff --git a/bot/exts/filtering/_ui/filter_list.py b/bot/exts/filtering/_ui/filter_list.py index 26852f13b..051521f1e 100644 --- a/bot/exts/filtering/_ui/filter_list.py +++ b/bot/exts/filtering/_ui/filter_list.py @@ -1,12 +1,174 @@ -from typing import Callable +from __future__ import annotations + +from typing import Any, Callable import discord -from discord import Interaction, Member, User +from botcore.site_api import ResponseCodeError +from discord import Embed, Interaction, Member, SelectOption, User +from discord.ext.commands import BadArgument + +from bot.exts.filtering._filter_lists import FilterList, ListType +from bot.exts.filtering._ui.ui import ( + CustomCallbackSelect, EditBaseView, MISSING, SETTINGS_DELIMITER, format_response_error, parse_value, + populate_embed_from_dict +) +from bot.exts.filtering._utils import repr_equals, to_serializable # Amount of seconds to confirm the operation. DELETION_TIMEOUT = 60 +def settings_converter(loaded_settings: dict, input_data: str) -> dict[str, Any]: + """Parse a string representing settings, and validate the setting names.""" + if not input_data: + return {} + + parsed = SETTINGS_DELIMITER.split(input_data) + if not parsed: + return {} + + settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} + for setting in settings: + if setting not in loaded_settings: + raise BadArgument(f"{setting!r} is not a recognized setting.") + else: + type_ = loaded_settings[setting][2] + try: + parsed_value = parse_value(settings.pop(setting), type_) + settings[setting] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + + return settings + + +def build_filterlist_repr_dict(filter_list: FilterList, list_type: ListType, new_settings: dict) -> dict: + """Build a dictionary of field names and values to pass to `_build_embed_from_dict`.""" + # 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.dict())) + + # Add new values. It's done in this way to preserve field order, since the new_values won't have all settings. + total_values = {} + for name, value in default_setting_values.items(): + if name not in new_settings or repr_equals(new_settings[name], value): + total_values[name] = value + else: + total_values[f"{name}~"] = new_settings[name] + + return total_values + + +class FilterListEditView(EditBaseView): + """A view used to edit a filter's settings before updating the database.""" + + def __init__( + self, + filter_list: FilterList, + list_type: ListType, + new_settings: dict, + loaded_settings: dict, + author: User, + embed: Embed, + confirm_callback: Callable + ): + super().__init__(author) + self.filter_list = filter_list + self.list_type = list_type + self.settings = new_settings + self.loaded_settings = loaded_settings + self.embed = embed + self.confirm_callback = confirm_callback + + self.settings_repr_dict = build_filterlist_repr_dict(filter_list, list_type, new_settings) + 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(self.type_per_setting_name)], + 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.filter_list, 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] + if setting_name in self.settings_repr_dict: + return self.settings_repr_dict[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 + + default_value = self.filter_list.default(self.list_type, setting_name) + if not repr_equals(setting_value, default_value): + self.settings[setting_name] = setting_value + # If there's already a new value, remove it, since the new value is the same as the default. + elif setting_name in self.settings: + self.settings.pop(setting_name) + + 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) -> FilterListEditView: + """Create a copy of this view.""" + return FilterListEditView( + self.filter_list, + self.list_type, + self.settings, + self.loaded_settings, + self.author, + self.embed, + self.confirm_callback + ) + + class DeleteConfirmationView(discord.ui.View): """A view to confirm the deletion of a filter list.""" diff --git a/bot/exts/filtering/_ui/ui.py b/bot/exts/filtering/_ui/ui.py index b31094b25..dc3bd01c9 100644 --- a/bot/exts/filtering/_ui/ui.py +++ b/bot/exts/filtering/_ui/ui.py @@ -1,17 +1,28 @@ from __future__ import annotations +import re +from abc import ABC, abstractmethod from enum import EnumMeta +from functools import partial from typing import Any, Callable, Coroutine, Optional, TypeVar, Union import discord from botcore.site_api import ResponseCodeError +from botcore.utils import scheduling from botcore.utils.logging import get_logger from discord import Embed, Interaction from discord.ext.commands import Context -from discord.ui.select import MISSING, SelectOption +from discord.ui.select import MISSING as SELECT_MISSING, SelectOption 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 +# Number of seconds before a settings editing view timeout. +EDIT_TIMEOUT = 600 # Number of seconds before timeout of an editing component. COMPONENT_TIMEOUT = 180 # Max length of modal title @@ -20,9 +31,28 @@ MAX_MODAL_TITLE_LENGTH = 45 MAX_SELECT_ITEMS = 25 MAX_EMBED_DESCRIPTION = 4000 +SETTINGS_DELIMITER = re.compile(r"\s+(?=\S+=\S+)") +SINGLE_SETTING_PATTERN = re.compile(r"\w+=.+") + +# Sentinel value to denote that a value is missing +MISSING = object() + T = TypeVar('T') +def populate_embed_from_dict(embed: Embed, data: dict) -> None: + """Populate a Discord embed by populating fields from the given dict.""" + for setting, value in data.items(): + if setting.startswith("_"): + continue + if type(value) in (set, tuple): + value = list(value) + 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) + + def remove_optional(type_: type) -> tuple[bool, type]: """Return whether the type is Optional, and the Union of types which aren't None.""" if not hasattr(type_, "__args__"): @@ -133,11 +163,11 @@ class CustomCallbackSelect(discord.ui.Select): self, callback: Callable[[Interaction, discord.ui.Select], Coroutine[None]], *, - custom_id: str = MISSING, + custom_id: str = SELECT_MISSING, placeholder: str | None = None, min_values: int = 1, max_values: int = 1, - options: list[SelectOption] = MISSING, + options: list[SelectOption] = SELECT_MISSING, disabled: bool = False, row: int | None = None, ): @@ -330,3 +360,64 @@ class EnumSelectView(discord.ui.View): def __init__(self, setting_name: str, enum_cls: EnumMeta, update_callback: Callable): super().__init__(timeout=COMPONENT_TIMEOUT) self.add_item(self.EnumSelect(setting_name, enum_cls, update_callback)) + + +class EditBaseView(ABC, discord.ui.View): + """A view used to edit embed fields based on a provided type.""" + + def __init__(self, author: discord.User): + super().__init__(timeout=EDIT_TIMEOUT) + self.author = author + self.type_per_setting_name = {} + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.author.id + + async def _prompt_new_value(self, interaction: Interaction, select: discord.ui.Select) -> None: + """Prompt the user to give an override value for the setting they selected, and respond to the interaction.""" + setting_name = select.values[0] + type_ = self.type_per_setting_name[setting_name] + is_optional, type_ = remove_optional(type_) + if hasattr(type_, "__origin__"): # In case this is a types.GenericAlias or a typing._GenericAlias + type_ = type_.__origin__ + new_view = self.copy() + # This is in order to not block the interaction response. There's a potential race condition here, since + # a view's method is used without guaranteeing the task completed, but since it depends on user input + # realistically it shouldn't happen. + scheduling.create_task(interaction.message.edit(view=new_view)) + update_callback = partial(new_view.update_embed, interaction_or_msg=interaction.message) + if type_ is bool: + 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) + 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), + ephemeral=True + ) + elif isinstance(type_, EnumMeta): + view = EnumSelectView(setting_name, type_, update_callback) + await interaction.response.send_message(f"Choose a value for `{setting_name}`:", view=view, ephemeral=True) + else: + await interaction.response.send_modal(FreeInputModal(setting_name, not is_optional, type_, update_callback)) + self.stop() + + @abstractmethod + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + + @abstractmethod + async def update_embed(self, interaction_or_msg: Interaction | discord.Message) -> 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. + """ + + @abstractmethod + def copy(self) -> EditBaseView: + """Create a copy of this view.""" diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 0bcf485c0..a906b6a41 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -15,7 +15,7 @@ from discord.utils import escape_markdown import bot import bot.exts.filtering._ui.filter as filters_ui from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES, Webhooks +from bot.constants import Colours, MODERATION_ROLES, 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 @@ -23,7 +23,7 @@ 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 +from bot.exts.filtering._ui.filter_list import DeleteConfirmationView, FilterListEditView, settings_converter from bot.exts.filtering._ui.ui import ArgumentCompletionView from bot.exts.filtering._utils import past_tense, to_serializable from bot.log import get_logger @@ -409,34 +409,34 @@ class Filtering(Cog): await patch_func( ctx.message, filter_list, list_type, filter_type, content, description, settings, filter_settings ) + return - else: - embed = Embed(colour=Colour.blue()) - embed.description = f"`{filter_.content}`" - if description: - embed.description += f" - {description}" - embed.set_author( - name=f"Filter #{filter_id} - {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}`." - )) - - view = filters_ui.SettingsEditView( - filter_list, - list_type, - filter_type, - content, - description, - settings, - filter_settings, - self.loaded_settings, - self.loaded_filter_settings, - ctx.author, - embed, - patch_func - ) - await ctx.send(embed=embed, reference=ctx.message, view=view) + embed = Embed(colour=Colour.blue()) + embed.description = f"`{filter_.content}`" + if description: + embed.description += f" - {description}" + embed.set_author( + name=f"Filter #{filter_id} - {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}`." + )) + + view = filters_ui.FilterEditView( + filter_list, + list_type, + filter_type, + content, + description, + settings, + filter_settings, + self.loaded_settings, + self.loaded_filter_settings, + ctx.author, + embed, + patch_func + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) @filter.command(name="delete", aliases=("d", "remove")) async def f_delete(self, ctx: Context, filter_id: int) -> None: @@ -524,7 +524,50 @@ class Filtering(Cog): ) await ctx.send(embed=embed) + @filterlist.command(name="edit", aliases=("e",)) + @has_any_role(Roles.admins) + async def fl_edit( + self, + ctx: Context, + noui: Optional[Literal["noui"]], + list_type: Optional[list_type_converter] = None, + list_name: Optional[str] = None, + *, + settings: str | None + ) -> None: + """ + Edit the filter list. + + Unless `noui` is specified, a UI will be provided to edit the settings before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + """ + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + settings = settings_converter(self.loaded_settings, settings) + if noui: + await self._patch_filter_list(ctx.message, filter_list, list_type, settings) + + embed = Embed(colour=Colour.blue()) + embed.set_author(name=f"{past_tense(list_type.name.lower())} {filter_list.name} Filter List".title()) + embed.set_footer(text="Field names with a ~ have values which change the existing value in the filter list.") + + view = FilterListEditView( + filter_list, + list_type, + settings, + self.loaded_settings, + ctx.author, + embed, + self._patch_filter_list + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + @filterlist.command(name="delete", aliases=("remove",)) + @has_any_role(Roles.admins) async def fl_delete( self, ctx: Context, list_type: Optional[list_type_converter] = None, list_name: Optional[str] = None ) -> None: @@ -699,34 +742,34 @@ class Filtering(Cog): ) except ValueError as e: raise BadArgument(str(e)) + return - else: - embed = Embed(colour=Colour.blue()) - embed.description = f"`{content}`" if content else "*No content*" - if description: - embed.description += f" - {description}" - embed.set_author( - name=f"New Filter - {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}`." - )) - - view = filters_ui.SettingsEditView( - filter_list, - list_type, - filter_type, - content, - description, - settings, - filter_settings, - self.loaded_settings, - self.loaded_filter_settings, - ctx.author, - embed, - self._post_new_filter - ) - await ctx.send(embed=embed, reference=ctx.message, view=view) + embed = Embed(colour=Colour.blue()) + embed.description = f"`{content}`" if content else "*No content*" + if description: + embed.description += f" - {description}" + embed.set_author( + name=f"New Filter - {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}`." + )) + + view = filters_ui.FilterEditView( + filter_list, + list_type, + filter_type, + content, + description, + settings, + filter_settings, + self.loaded_settings, + self.loaded_filter_settings, + ctx.author, + embed, + self._post_new_filter + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) @staticmethod def _identical_filters_message(content: str, filter_list: FilterList, list_type: ListType, filter_: Filter) -> str: @@ -794,20 +837,27 @@ class Filtering(Cog): # If the setting is not in `settings`, the override was either removed, or there wasn't one in the first place. for current_settings in (filter_.actions, filter_.validations): if current_settings: - for _, setting_entry in current_settings.items(): + for setting_entry in current_settings.values(): settings.update({setting: None for setting in setting_entry.dict() if setting not in settings}) - list_id = filter_list.list_ids[list_type] description = description or None payload = { - "filter_list": list_id, "content": content, "description": description, - "additional_field": json.dumps(filter_settings), **settings + "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) 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) + @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) + 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}") + # endregion |