diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py new file mode 100644 index 00000000..76a7bbbf --- /dev/null +++ b/src/utils/ui/config.py @@ -0,0 +1,304 @@ +from typing import Optional +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle + +from meta import conf, LionBot +from meta.errors import UserInputError +from wards import i_low_management +from babel.translator import ctx_translator, LazyStr + +from ..lib import tabulate +from . import LeoUI, util_babel, error_handler_for, FastModal, ModalRetryUI + + +_p = util_babel._p + + +class ConfigEditor(FastModal): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @error_handler_for(UserInputError) + async def rerequest(self, interaction, error): + await ModalRetryUI(self, error.msg).respond_to(interaction) + + +class ConfigUI(LeoUI): + # TODO: Migrate to a subclass of MessageUI + _listening = {} + setting_classes = [] + + edit_modal_title = _p('ui:configui|modal:edit|title', "Setting Editor") + + def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs): + super().__init__(**kwargs) + self.bot = bot + self.guildid = guildid + self.channelid = channelid + + # Original interaction, if this UI is sent as an interaction response + self._original: Optional[discord.Interaction] = None + + # Message containing the UI, when the UI is sent as a followup + self._message: Optional[discord.Message] = None + + # Refresh lock, to avoid cache collisions + self._refresh_lock = asyncio.Lock() + + # Instances of the settings this UI is managing + self.instances = () + + async def interaction_check(self, interaction: discord.Interaction): + """ + Default requirement for a Config UI is low management (i.e. manage_guild permissions). + """ + return await i_low_management(interaction) + + async def cleanup(self): + self._listening.pop(self.channelid, None) + for instance in self.instances: + instance.deregister_callback(self.id) + try: + if self._original is not None and not self._original.is_expired(): + await self._original.edit_original_response(view=None) + self._original = None + if self._message is not None: + await self._message.edit(view=None) + self._message = None + except discord.HTTPException: + pass + + @button(label="EDIT_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_button(self, press: discord.Interaction, pressed: Button): + """ + Bulk edit the current setting instances. + + There must be no more than 5 instances for this to work! + They are all assumed to support `input_formatted` and `parse_string`. + Errors should raise instances of `UserInputError`, and will be caught for retry. + """ + t = ctx_translator.get().t + instances = self.instances + items = [setting.input_field for setting in instances] + strings = [item.value for item in items] + modal = ConfigEditor(*items, title=t(self.edit_modal_title)) + + @modal.submit_callback() + async def save_settings(interaction: discord.Interaction): + # NOTE: Cannot respond with a defer because we need ephemeral error + modified = [] + for setting, field, original in zip(instances, items, strings): + if field.value != original: + # Setting was modified, attempt to parse + input_value = field.value.strip() + if not input_value: + # None input, reset the setting + new_data = None + else: + # If this raises a UserInputError, it will be caught and the modal retried + new_data = await setting._parse_string(setting.parent_id, input_value) + setting.data = new_data + modified.append(setting) + if modified: + await interaction.response.defer(thinking=True) + # Write the settings to disk + for setting in modified: + # TODO: Again, need a way of batching these + # Also probably put them in a transaction + await setting.write() + # TODO: Send modified ack + desc = '\n'.join(f"{conf.emojis.tick} {setting.update_message}" for setting in modified) + await interaction.edit_original_response( + embed=discord.Embed( + colour=discord.Colour.brand_green(), + description=desc + ) + ) + else: + await interaction.response.defer(thinking=False) + + await press.response.send_modal(modal) + + async def edit_button_refresh(self): + t = ctx_translator.get().t + self.edit_button.label = t(_p( + 'ui:configui|button:edit|label', + "Bulk Edit" + )) + + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def close_button(self, press: discord.Interaction, pressed: Button): + """ + Close the UI, if possible. + """ + await press.response.defer() + try: + if self._original is not None and not self._original.is_expired(): + await self._original.delete_original_response() + self._original = None + elif self._message is not None: + await self._message.delete() + self._message = None + except discord.HTTPException: + self._original = None + self._message = None + + await self.close() + + async def close_button_refresh(self): + pass + + @button(label="RESET_PLACEHOLDER", style=ButtonStyle.red) + async def reset_button(self, press: discord.Interaction, pressed: Button): + """ + Reset the controlled settings. + """ + await press.response.defer() + + for instance in self.instances: + instance.data = None + await instance.write() + + async def reset_button_refresh(self): + t = self.bot.translator.t + self.reset_button.label = t(_p( + 'ui:guild_config_base|button:reset|label', "Reset" + )) + + async def refresh_components(self): + """ + Refresh UI layout and individual components. + """ + raise NotImplementedError + + async def reload(self): + """ + Reload UI data, including instantiating settings. + + Default implementation directly re-instantiates each setting in self.setting_classes. + Should be overridden for conditional settings or more advanced caching methods. + """ + self.instances = tuple([ + await cls.get(self.guildid) for cls in self.setting_classes + ]) + + async def make_message(self): + """ + UI message arguments, to be calculated after reload. + """ + raise NotImplementedError + + async def redraw(self, thinking: Optional[discord.Interaction] = None): + """ + Redraw the UI. + + If a thinking interaction is provided, + deletes the response while redrawing. + """ + args = await self.make_message() + if thinking is not None and not thinking.is_expired() and thinking.response.is_done(): + asyncio.create_task(thinking.delete_original_response()) + try: + if self._original and not self._original.is_expired(): + await self._original.edit_original_response(**args.edit_args, view=self) + elif self._message: + await self._message.edit(**args.edit_args, view=self) + else: + # Interaction expired or we already closed. Exit quietly. + await self.close() + except discord.HTTPException: + # Some unknown communication error, nothing we can safely do. Exit quietly. + await self.close() + + async def refresh(self, *args, thinking: Optional[discord.Interaction] = None): + """ + Refresh the UI. + """ + async with self._refresh_lock: + # Refresh data + await self.reload() + # Refresh UI components and layout + await self.refresh_components() + # Redraw UI message + await self.redraw(thinking=thinking) + + async def run(self, interaction: discord.Interaction): + if old := self._listening.get(self.channelid, None): + await old.close() + + await self.reload() + await self.refresh_components() + args = await self.make_message() + + if interaction.response.is_done(): + # Start UI using followup message + self._message = await interaction.followup.send(**args.send_args, view=self) + else: + # Start UI using interaction response + self._original = interaction + await interaction.response.send_message(**args.send_args, view=self) + + for instance in self.instances: + # Attach refresh callback to each instance + instance.register_callback(self.id)(self.refresh) + + # Register this UI as listening for updates in this channel + self._listening[self.channelid] = self + + +class DashboardSection: + """ + Represents a section of a configuration Dashboard. + """ + section_name: LazyStr = None + setting_classes = [] + configui = None + + def __init__(self, bot: LionBot, guildid: int): + self.bot = bot + self.guildid = guildid + + # List of instances of the contained setting classes + # Populated in load() + self.instances = [] + + async def load(self): + """ + Initialise the contained settings. + """ + instances = [] + for cls in self.setting_classes: + instance = await cls.get(self.guildid) + instances.append(instance) + self.instances = instances + return self + + def apply_to(self, page: discord.Embed): + """ + Apply this section to the given dashboard page. + + Usually just defines and adds an embed field with the setting table. + """ + t = ctx_translator.get().t + + # TODO: Header/description field + table = self.make_table() + page.add_field( + name=t(self.section_name), + value=table, + inline=False + ) + + def make_table(self): + rows = [] + for setting in self.instances: + name = setting.display_name + value = setting.formatted + rows.append((name, value, setting.desc)) + table_rows = tabulate( + *rows, + row_format="[`{invis}{key:<{pad}}{colon}`](https://lionbot.org \"{field[2]}\")\t{value}" + ) + return '\n'.join(table_rows)