diff options
| author | 2022-10-11 10:29:57 +0300 | |
|---|---|---|
| committer | 2022-10-11 10:29:57 +0300 | |
| commit | fb4117bcee3028f3679ecfd99399f3b089c2beb9 (patch) | |
| tree | 2ff3f4ccad7e85821111346687d862d97b8c21e9 | |
| parent | Add filter list edit command (diff) | |
Filter list add command
| -rw-r--r-- | bot/exts/filtering/_ui/filter_list.py | 103 | ||||
| -rw-r--r-- | bot/exts/filtering/_ui/ui.py | 11 | ||||
| -rw-r--r-- | bot/exts/filtering/_utils.py | 21 | ||||
| -rw-r--r-- | bot/exts/filtering/filtering.py | 79 |
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}") |