From f0c796ce3191cff5b13ace82cb2a6c8e8a4a0303 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 31 Jul 2023 21:06:51 +0300 Subject: [PATCH] rewrite: New Rolemenu system. (Incomplete) --- data/migration/v12-13/rolemenus.sql | 53 ++ src/modules/__init__.py | 1 + src/modules/rolemenus/__init__.py | 10 + src/modules/rolemenus/cog.py | 373 +++++++++ src/modules/rolemenus/data.py | 69 ++ src/modules/rolemenus/lib.py | 0 src/modules/rolemenus/menuoptions.py | 145 ++++ src/modules/rolemenus/rolemenu.py | 42 + src/modules/rolemenus/roleoptions.py | 149 ++++ src/modules/rolemenus/templates.py | 207 +++++ src/modules/rolemenus/ui/menu.py | 0 src/modules/rolemenus/ui/menueditor.py | 745 +++++++++++++++++ src/modules/rolemenus/ui/menus.py | 0 src/modules/rolemenus/ui/msgeditor.py | 0 src/modules/rolemenus/ui/roleeditor.py | 0 src/utils/ansi.py | 97 +++ src/utils/ui/__init__.py | 1 + src/utils/ui/leo.py | 22 +- src/utils/ui/micros.py | 1 + src/utils/ui/msgeditor.py | 1022 ++++++++++++++++++++++++ src/wards.py | 77 ++ 21 files changed, 3013 insertions(+), 1 deletion(-) create mode 100644 data/migration/v12-13/rolemenus.sql create mode 100644 src/modules/rolemenus/__init__.py create mode 100644 src/modules/rolemenus/cog.py create mode 100644 src/modules/rolemenus/data.py create mode 100644 src/modules/rolemenus/lib.py create mode 100644 src/modules/rolemenus/menuoptions.py create mode 100644 src/modules/rolemenus/rolemenu.py create mode 100644 src/modules/rolemenus/roleoptions.py create mode 100644 src/modules/rolemenus/templates.py create mode 100644 src/modules/rolemenus/ui/menu.py create mode 100644 src/modules/rolemenus/ui/menueditor.py create mode 100644 src/modules/rolemenus/ui/menus.py create mode 100644 src/modules/rolemenus/ui/msgeditor.py create mode 100644 src/modules/rolemenus/ui/roleeditor.py create mode 100644 src/utils/ansi.py create mode 100644 src/utils/ui/msgeditor.py diff --git a/data/migration/v12-13/rolemenus.sql b/data/migration/v12-13/rolemenus.sql new file mode 100644 index 00000000..28535dfd --- /dev/null +++ b/data/migration/v12-13/rolemenus.sql @@ -0,0 +1,53 @@ +DROP TABLE IF EXISTS role_menu_history CASCADE; +DROP TABLE IF EXISTS role_menu_roles CASCADE; +DROP TABLE IF EXISTS role_menus CASCADE; +DROP TYPE IF EXISTS RoleMenuType; + + +CREATE TYPE RoleMenuType AS ENUM( + 'REACTION', + 'BUTTON', + 'DROPDOWN' +); + + +CREATE TABLE role_menus( + menuid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + channelid BIGINT, + messageid BIGINT, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT True, + required_roleid BIGINT, + sticky BOOLEAN, + refunds BOOLEAN, + obtainable INTEGER, + menutype RoleMenuType NOT NULL, + templateid INTEGER, + rawmessage TEXT +); + + +CREATE TABLE role_menu_roles( + menuroleid SERIAL PRIMARY KEY, + menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE, + roleid BIGINT NOT NULL, + label TEXT NOT NULL, + emoji TEXT, + description TEXT, + price INTEGER, + duration INTEGER, + rawreply TEXT +); + + +CREATE TABLE role_menu_history( + equipid SERIAL PRIMARY KEY, + menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE, + roleid BIGINT NOT NULL, + userid BIGINT NOT NULL, + obtained_at TIMESTAMPTZ NOT NULL, + transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, + expires_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ +); diff --git a/src/modules/__init__.py b/src/modules/__init__.py index ba855f3b..cb475d5e 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -13,6 +13,7 @@ active = [ '.statistics', '.pomodoro', '.rooms', + '.rolemenus', '.meta', '.test', ] diff --git a/src/modules/rolemenus/__init__.py b/src/modules/rolemenus/__init__.py new file mode 100644 index 00000000..ac6858b6 --- /dev/null +++ b/src/modules/rolemenus/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +logger = logging.getLogger(__name__) +babel = LocalBabel('rolemenus') + + +async def setup(bot): + from .cog import RoleMenuCog + await bot.add_cog(RoleMenuCog(bot)) diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py new file mode 100644 index 00000000..3aeef480 --- /dev/null +++ b/src/modules/rolemenus/cog.py @@ -0,0 +1,373 @@ +from typing import Optional +from collections import defaultdict +import asyncio +import json + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds +from discord.app_commands import Range +from discord.app_commands.transformers import AppCommandOptionType as cmdopt + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from meta.errors import ResponseTimedOut, UserInputError, UserCancelled +from meta.sharding import THIS_SHARD +from utils.lib import utc_now, error_embed +from utils.ui import Confirm, ChoicedEnum, Transformed +from constants import MAX_COINS + +from wards import low_management_ward + +from . import babel, logger +from .data import RoleMenuData, MenuType +from .rolemenu import RoleMenu, RoleMenuRole +from .ui.menueditor import MenuEditor +from .templates import templates + + +_p = babel._p + + +class MenuStyleParam(ChoicedEnum): + REACTION = ( + _p('argtype:menu_style|opt:reaction', "Reaction Roles"), + MenuType.REACTION + ) + BUTTON = ( + _p('argtype:menu_style|opt:button', "Button Menu"), + MenuType.BUTTON + ) + DROPDOWN = ( + _p('argtype:menu_style|opt:dropdown', "Dropdown Menu"), + MenuType.DROPDOWN + ) + + @property + def choice_name(self): + return self.value[0] + + @property + def choice_value(self) -> str: + return self.name + + @property + def data(self) -> MenuType: + return self.value[1] + + +class RoleMenuCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + self.data = bot.db.load_registry(RoleMenuData()) + + # Menu caches + self.guild_menus = defaultdict(dict) # guildid -> menuid -> RoleMenu + self.guild_menu_messages = defaultdict(dict) # guildid -> messageid -> RoleMenu + + # ----- Initialisation ----- + async def cog_load(self): + await self.data.init() + + if self.bot.is_ready(): + await self.initialise() + + async def cog_unload(self): + ... + + @LionCog.listener('on_ready') + @log_wrap(action="Initialise Role Menus") + async def initialise(self): + ... + + # ----- Cog API ----- + async def register_menus(*menus): + ... + + async def deregister_menus(*menus): + ... + + # ----- Private Utils ----- + async def _parse_msg(self, guild: discord.Guild, msgstr: str) -> discord.Message: + """ + Parse a message reference link into a Message. + """ + t = self.bot.translator.t + + error = None + message = None + splits = msgstr.strip().rsplit('/', maxsplit=2) + if len(splits) == 2 and splits[0].isdigit() and splits[1].isdigit(): + chid, mid = map(int, splits) + channel = guild.get_channel(chid) + if channel is not None: + try: + message = await channel.fetch_message(mid) + except discord.NotFound: + error = t(_p( + 'parse:message_link|suberror:message_dne', + "Could not find the linked message, has it been deleted?" + )) + except discord.Forbidden: + error = t(_p( + 'parse:message_link|suberror:no_perms', + "Insufficient permissions! I need the `MESSAGE_HISTORY` permission in {channel}." + )).format(channel=channel.menion) + else: + error = t(_p( + 'parse:message_link|suberror:channel_dne', + "The channel `{channelid}` could not be found in this server." + )).format(channelid=chid) + else: + error = t(_p( + 'parse:message_link|suberror:malformed_link', + "Malformed message link. Please copy the link by right clicking the target message." + )) + + if message is None: + raise UserInputError( + t(_p( + 'parse:message_link|error', + "Failed to resolve the provided message link.\n**ERROR:** {error}" + )).format(error=error) + ) + + return message + + async def _parse_menu(self, menustr: str, create=False) -> RoleMenu: + ... + + async def _acmpl_menu(self, interaction: discord.Interaction, partial: str, allow_new=False): + ... + + async def _parse_role(self, menu, rolestr) -> RoleMenuRole: + """ + Parse a provided role menu role. + This can be given as 'rid-', role mention, or role id. + """ + ... + + async def _acmpl_role(self, interaction: discord.Interaction, partial: str): + ... + + # ----- Event Handlers ----- + # Message delete handler + # Role delete handler + # Reaction handler + # Guild leave handler (stop listening) + # Guild join handler (start listening) + + # ----- Context Menu ----- + + # ----- Commands ----- + + @cmds.hybrid_command( + name=_p('cmd:rolemenus', "rolemenus"), + description=_p( + 'cmd:rolemenus|desc', + "View and configure the role menus in this server." + ) + ) + async def rolemenus_cmd(self, ctx: LionContext): + # Spawn the menus UI + # Maybe accept a channel here to restrict the menus + ... + + @cmds.hybrid_group( + name=_p('group:rolemenu', "rolemenu"), + description=_p( + 'group:rolemenu|desc', + "Base command group for role menu configuration." + ) + ) + @appcmds.guild_only() + async def rolemenu_group(self, ctx: LionBot): + ... + + @rolemenu_group.command( + name=_p('cmd:rolemenu_create', "newmenu"), + description=_p( + 'cmd:rolemenu_create|desc', + "Create a new role menu (optionally using an existing message)" + ) + ) + @appcmds.choices( + template=[ + template.as_choice() for template in templates.values() + ] + ) + async def rolemenu_create_cmd(self, ctx: LionContext, + name: appcmds.Range[str, 1, 64], + message: Optional[str] = None, + menu_style: Optional[Transformed[MenuStyleParam, cmdopt.string]] = None, + required_role: Optional[discord.Role] = None, + sticky: Optional[bool] = None, + refunds: Optional[bool] = None, + obtainable: Optional[appcmds.Range[int, 1, 25]] = None, + template: Optional[appcmds.Choice[int]] = None, + ): + # Type checking guards + if ctx.guild is None: + return + if ctx.interaction is None: + return + + t = self.bot.translator.t + await ctx.interaction.response.defer(thinking=True) + + # Parse provided target message if given + if message is None: + target_message = None + target_mine = True + else: + # Parse provided message link into a Message + target_message: discord.Message = await self._parse_msg(message) + target_mine = (target_message.author == ctx.guild.me) + + # Check that this message is not already attached to a role menu + if target_message.id in (menu.data.messageid for menu in self.guild_menus[ctx.guild.id].values()): + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:message_exists', + "The message {link} already has a role menu! Use {edit_cmd} to edit it." + )).format( + link=target_message.jump_url, + edit_cmd=self.bot.core.mention_cache['rolemenu edit'] + ) + ) + + # Default menu type is Button if we own the message, reaction otherwise + if menu_style is not None: + menu_type = menu_style.data + elif target_mine: + menu_type = MenuType.BUTTON + else: + menu_type = MenuType.REACTION + + # Handle incompatible options from unowned target message + if not target_mine: + if menu_type is not MenuType.REACTION: + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:incompatible_style', + "I cannot create a `{style}` style menu on a message I didn't send (Discord restriction)." + )).format(style=t(menu_style.value[0])) + ) + + # Parse menu options if given + name = name.strip() + if name.lower() in (menu.data.name.lower() for menu in self.guild_menus[ctx.guild.id].values()): + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:name_exists', + "A rolemenu called `{name}` already exists! Use {edit_cmd} to edit it." + )).format(name=name, edit_cmd=self.bot.core.mention_cache['rolemenu edit']) + ) + + templateid = template.value if template is not None else None + if target_message: + message_data = {} + message_data['content'] = target_message.content + if target_message.embeds: + message_data['embed'] = target_message.embeds[0].to_dict() + rawmessage = json.dumps(message_data) + else: + rawmessage = None + if templateid is None: + templateid = 0 + + # Create RoleMenu data, set options if given + data = await self.data.RoleMenu.create( + guildid=ctx.guild.id, + channelid=target_message.channel.id if target_message else None, + messageid=target_message.id if target_message else None, + name=name, + enabled=True, + required_roleid=required_role.id if required_role else None, + sticky=sticky, + refunds=refunds, + obtainable=obtainable, + menutype=menu_type, + templateid=templateid, + rawmessage=rawmessage, + ) + # Create RoleMenu + menu = RoleMenu(self.bot, data, []) + + # Open editor, with preview if not a reaction role message + editor = MenuEditor(self.bot, menu, callerid=ctx.author.id) + await editor.run(ctx.interaction) + await editor.wait() + + @rolemenu_group.command( + name=_p('cmd:rolemenu_edit', "editmenu"), + description=_p( + 'cmd:rolemenu_edit|desc', + "Edit an existing (or in-creation) role menu." + ) + ) + async def rolemenu_edit_cmd(self, ctx: LionContext): + # Parse target + # Parse provided options + # Set options if provided + # Open editor with preview + ... + + @rolemenu_group.command( + name=_p('cmd:rolemenu_delete', "delmenu"), + description=_p( + 'cmd:rolemenu_delete|desc', + "Delete a role menu." + ) + ) + async def rolemenu_delete_cmd(self, ctx: LionContext): + # Parse target + # Delete target + ... + + @rolemenu_group.command( + name=_p('cmd:rolemenu_addrole', "addrole"), + description=_p( + 'cmd:rolemenu_addrole|desc', + "Add a new role to a new or existing role menu." + ) + ) + async def rolemenu_addrole_cmd(self, ctx: LionContext, + role: discord.Role, + message: Optional[str] = None, + ): + # Parse target menu, may need to create here + # Parse target role + # Check author permissions + # Parse role options + # Create RoleMenuRole + # Ack, with open editor button + ... + + @rolemenu_group.command( + name=_p('cmd:rolemenu_editrole', "editrole"), + description=_p( + 'cmd:rolemenu_editrole|desc', + "Edit role options in a role menu (supports in-creation menus)" + ) + ) + async def rolemenu_editrole_cmd(self, ctx: LionContext): + # Parse target menu + # Parse target role + # Check author permissions + # Parse role options + # Either ack changes or open the RoleEditor + ... + + @rolemenu_group.command( + name=_p('cmd:rolemenu_delrole', "delrole"), + description=_p( + 'cmd:rolemenu_delrole|desc', + "Remove a role from a role menu." + ) + ) + async def rolemenu_delrole_cmd(self, ctx: LionContext): + # Parse target menu + # Parse target role + # Remove role + ... diff --git a/src/modules/rolemenus/data.py b/src/modules/rolemenus/data.py new file mode 100644 index 00000000..cca6b292 --- /dev/null +++ b/src/modules/rolemenus/data.py @@ -0,0 +1,69 @@ +from enum import Enum + +from data import Registry, RowModel, RegisterEnum, Column +from data.columns import Integer, Timestamp, String, Bool + + +class MenuType(Enum): + REACTION = 'REACTION', + BUTTON = 'BUTTON', + DROPDOWN = 'DROPDOWN', + + +class RoleMenuData(Registry): + MenuType = RegisterEnum(MenuType, name='RoleMenuType') + + class RoleMenu(RowModel): + _tablename_ = 'role_menus' + _cache_ = {} + + menuid = Integer(primary=True) + guildid = Integer() + + channelid = Integer() + messageid = Integer() + + name = String() + enabled = Bool() + + required_roleid = Integer() + sticky = Bool() + refunds = Bool() + obtainable = Integer() + + menutype: Column[MenuType] = Column() + templateid = Integer() + rawmessage = String() + + class RoleMenuRole(RowModel): + _tablename_ = 'role_menu_roles' + _cache_ = {} + + menuroleid = Integer(primary=True) + + menuid = Integer() + roleid = Integer() + + label = String() + emoji = String() + description = String() + + price = Integer() + duration = Integer() + + rawreply = String() + + class RoleMenuHistory(RowModel): + _tablename_ = 'role_menu_history' + _cache_ = None + + equipid = Integer(primary=True) + + menuid = Integer() + roleid = Integer() + userid = Integer() + + obtained_at = Timestamp() + transactionid = Integer() + expires_at = Timestamp() + expired_at = Timestamp() diff --git a/src/modules/rolemenus/lib.py b/src/modules/rolemenus/lib.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/rolemenus/menuoptions.py b/src/modules/rolemenus/menuoptions.py new file mode 100644 index 00000000..1dbdac90 --- /dev/null +++ b/src/modules/rolemenus/menuoptions.py @@ -0,0 +1,145 @@ +from typing import Optional + +import discord + +from meta.errors import UserInputError +from babel.translator import ctx_translator +from settings import ModelData +from settings.groups import SettingGroup, ModelConfig, SettingDotDict +from settings.setting_types import ( + RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting +) + +from .data import RoleMenuData +from . import babel + +_p = babel._p + + +# TODO: Write some custom accepts fields +# TODO: The *name* might be an important setting! + + +class RoleMenuConfig(ModelConfig): + settings = SettingDotDict() + _model_settings = set() + model = RoleMenuData.RoleMenu + + @property + def name(self): + return self.get(RoleMenuOptions.Name.setting_id) + + @property + def required_role(self): + return self.get(RoleMenuOptions.RequiredRole.setting_id) + + @property + def sticky(self): + return self.get(RoleMenuOptions.Sticky.setting_id) + + @property + def refunds(self): + return self.get(RoleMenuOptions.Refunds.setting_id) + + @property + def obtainable(self): + return self.get(RoleMenuOptions.Obtainable.setting_id) + + +class RoleMenuOptions(SettingGroup): + @RoleMenuConfig.register_model_setting + class Name(ModelData, StringSetting): + setting_id = 'name' + + _display_name = _p('menuset:name', "name") + _desc = _p( + 'menuset:name|desc', + "Brief name for this role menu." + ) + _long_desc = _p( + 'menuset:name|long_desc', + "The role menu name is displayed when selecting the menu in commands, " + "and as the title of most default menu layouts." + ) + _default = 'Untitled' + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.name.name + + @RoleMenuConfig.register_model_setting + class Sticky(ModelData, BoolSetting): + setting_id = 'sticky' + + _display_name = _p('menuset:sticky', "sticky_roles") + _desc = _p( + 'menuset:sticky|desc', + "Whether the menu can be used to unequip roles." + ) + _long_desc = _p( + 'menuset:sticky|long_desc', + "When enabled, members will not be able to remove equipped roles by selecting them in this menu. " + "Note that when disabled, " + "members will be able to unequip the menu roles even if they were not obtained from the menu." + ) + _default = False + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.sticky.name + + @RoleMenuConfig.register_model_setting + class Refunds(ModelData, BoolSetting): + setting_id = 'refunds' + + _display_name = _p('menuset:refunds', "refunds") + _desc = _p( + 'menuset:refunds|desc', + "Whether removing a role will refund the purchase price for that role." + ) + _long_desc = _p( + 'menuset:refunds|long_desc', + "When enabled, members who *purchased a role through this role menu* will obtain a full refund " + "when they remove the role through the menu.\n" + "**Refunds will only be given for roles purchased through the same menu.**\n" + "**The `sticky` option must be disabled to allow members to remove roles.**" + ) + _default = True + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.refunds.name + + @RoleMenuConfig.register_model_setting + class Obtainable(ModelData, IntegerSetting): + setting_id = 'obtainable' + + _display_name = _p('menuset:obtainable', "obtainable") + _desc = _p( + 'menuset:obtainable|desc', + "The maximum number of roles equippable from this menu." + ) + _long_desc = _p( + 'menus:obtainable|long_desc', + "Members will not be able to obtain more than this number of roles from this menu. " + "The counts roles that were not obtained through the rolemenu system." + ) + _default = None + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.obtainable.name + + @RoleMenuConfig.register_model_setting + class RequiredRole(ModelData, RoleSetting): + setting_id = 'required_role' + + _display_name = _p('menuset:required_role', "required_role") + _desc = _p( + 'menuset:required_role|desc', + "Initial role required to use this menu." + ) + _long_desc = _p( + 'menuset:required_role|long_desc', + "If set, only members who have the `required_role` will be able to obtain or remove roles using this menu." + ) + _default = None + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.required_roleid.name diff --git a/src/modules/rolemenus/rolemenu.py b/src/modules/rolemenus/rolemenu.py new file mode 100644 index 00000000..ed8cdd12 --- /dev/null +++ b/src/modules/rolemenus/rolemenu.py @@ -0,0 +1,42 @@ +from meta import LionBot + + +from .data import RoleMenuData as Data +from .menuoptions import RoleMenuConfig +from .roleoptions import RoleMenuRoleConfig + + +class RoleMenuRole: + def __init__(self, bot: LionBot, data: Data.RoleMenuRole): + self.bot = bot + self.data = data + self.config = RoleMenuRoleConfig(data.menuroleid, data) + + +class RoleMenu: + def __init__(self, bot: LionBot, data: Data.RoleMenu, roles): + self.bot = bot + self.data = data + self.config = RoleMenuConfig(data.menuid, data) + self.roles: list[RoleMenuRole] = roles + + self._message = None + + @property + def message(self): + return self._message + + async def fetch_message(self): + ... + + async def reload(self): + await self.data.refresh() + roledata = self.bot.get_cog('RoleMenuCog').data.RoleMenuRole + role_rows = await roledata.fetch_where(menuid=self.data.menuid) + self.roles = [RoleMenuRole(self.bot, row) for row in role_rows] + + async def make_view(self): + ... + + async def make_args(self): + ... diff --git a/src/modules/rolemenus/roleoptions.py b/src/modules/rolemenus/roleoptions.py new file mode 100644 index 00000000..4ef2d5b2 --- /dev/null +++ b/src/modules/rolemenus/roleoptions.py @@ -0,0 +1,149 @@ +from settings import ModelData +from settings.groups import SettingGroup, ModelConfig, SettingDotDict +from settings.setting_types import ( + RoleSetting, BoolSetting, StringSetting, DurationSetting +) +from core.setting_types import CoinSetting + +from .data import RoleMenuData +from . import babel + +_p = babel._p + + +class RoleMenuRoleConfig(ModelConfig): + settings = SettingDotDict() + _model_settings = set() + model = RoleMenuData.RoleMenuRole + + @property + def role(self): + return self.get(RoleMenuRoleOptions.Role.setting_id) + + @property + def label(self): + return self.get(RoleMenuRoleOptions.Label.setting_id) + + @property + def emoji(self): + return self.get(RoleMenuRoleOptions.Emoji.setting_id) + + @property + def description(self): + return self.get(RoleMenuRoleOptions.Description.setting_id) + + @property + def price(self): + return self.get(RoleMenuRoleOptions.Price.setting_id) + + @property + def duration(self): + return self.get(RoleMenuRoleOptions.Duration.setting_id) + + +class RoleMenuRoleOptions(SettingGroup): + @RoleMenuRoleConfig.register_model_setting + class Role(ModelData, RoleSetting): + setting_id = 'role' + + _display_name = _p('roleset:role', "role") + _desc = _p( + 'roleset:role|desc', + "The role associated to this menu item." + ) + _long_desc = _p( + 'roleset:role|long_desc', + "The role given when this menu item is selected in the role menu." + ) + + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.roleid.name + + @RoleMenuRoleConfig.register_model_setting + class Label(ModelData, StringSetting): + setting_id = 'role' + + _display_name = _p('roleset:label', "label") + _desc = _p( + 'roleset:label|desc', + "A short button label for this role." + ) + _long_desc = _p( + 'roleset:label|long_desc', + "A short name for this role, to be displayed in button labels, dropdown titles, and some menu layouts. " + "By default uses the Discord role name." + ) + + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.label.name + + @RoleMenuRoleConfig.register_model_setting + class Emoji(ModelData, StringSetting): + setting_id = 'emoji' + + _display_name = _p('roleset:emoji', "emoji") + _desc = _p( + 'roleset:emoji|desc', + "The emoji associated with this role." + ) + _long_desc = _p( + 'roleset:emoji|long_desc', + "The role emoji is used for the reaction (in reaction role menus), " + "and otherwise appears next to the role label in the button and dropdown styles. " + "The emoji is also displayed next to the role in most menu templates." + ) + + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.emoji.name + + @RoleMenuRoleConfig.register_model_setting + class Description(ModelData, StringSetting): + setting_id = 'description' + + _display_name = _p('roleset:description', "description") + _desc = _p( + 'roleset:description|desc', + "A longer description of this role." + ) + _long_desc = _p( + 'roleset:description|long_desc', + "The description is displayed under the role label in dropdown style menus. " + "It may also be used as a substitution key in custom role selection responses." + ) + + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.description.name + + @RoleMenuRoleConfig.register_model_setting + class Price(ModelData, CoinSetting): + setting_id = 'price' + + _display_name = _p('roleset:price', "price") + _desc = _p( + 'roleset:price|desc', + "Price of the role, in LionCoins." + ) + _long_desc = _p( + 'roleset:price|long_desc', + "How much the role costs when selected, in LionCoins." + ) + _default = 0 + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.price.name + + @RoleMenuRoleConfig.register_model_setting + class Duration(ModelData, DurationSetting): + setting_id = 'duration' + + _display_name = _p('roleset:duration', "duration") + _desc = _p( + 'roleset:duration|desc', + "Lifetime of the role after selection" + ) + _long_desc = _p( + 'roleset:duration|long_desc', + "Allows creation of 'temporary roles' which expire a given time after being equipped. " + "Refunds will not be given upon expiry." + ) + _model = RoleMenuData.RoleMenuRole + _column = RoleMenuData.RoleMenuRole.duration.name diff --git a/src/modules/rolemenus/templates.py b/src/modules/rolemenus/templates.py new file mode 100644 index 00000000..eeefa390 --- /dev/null +++ b/src/modules/rolemenus/templates.py @@ -0,0 +1,207 @@ +import discord +from discord.ui.select import SelectOption +from discord.app_commands import Choice + +from utils.lib import MessageArgs +from babel.translator import ctx_translator + +from . import babel + +_p = babel._p + + +DEFAULT_EMOJI = '🔲' + + +templates = {} + + +class Template: + def __init__(self, id, name, description, formatter): + self.id = id + self.name = name + self.description = description + self.formatter = formatter + + def as_option(self) -> SelectOption: + # Select options need to be strings, so we localise + t = ctx_translator.get().t + name = t(self.name) + description = t(self.description) + return SelectOption(label=name, value=str(self.id), description=description) + + def as_choice(self) -> Choice[int]: + # Appcmd choices are allowed to be LazyStrings, so we don't localise + return Choice(name=self.name, value=self.id) + + async def render_menu(self, menu) -> MessageArgs: + # TODO: Some error catching and logging might be good here + return await self.formatter(menu) + + +def register_template(id, name, description): + def wrapper(coro): + template = Template(id, name, description, coro) + templates[id] = template + return template + return wrapper + + +@register_template( + id=0, + name=_p( + 'template:simple|name', "Simple Menu" + ), + description=_p( + 'template:simple|desc', + "A simple embedded list of roles in the menu" + ) +) +async def simple_template(menu) -> MessageArgs: + menuroles = menu.roles + lines = [] + for menurole in menuroles: + parts = [] + emoji = menurole.config.emoji + role = menurole.config.role + price = menurole.config.price + duration = menurole.config.duration + + if emoji.data: + parts.append(emoji.formatted) + + parts.append(role.formatted) + + if price.data: + parts.append(f"({price.formatted})") + + if duration.data: + parts.append(f"({duration.formatted})") + + lines.append(' '.join(parts)) + + description = '\n'.join(lines) + + embed = discord.Embed( + title=menu.config.name.value, + description=description, + colour=discord.Colour.orange() + ) + return MessageArgs(embed=embed) + + +@register_template( + id=1, + name=_p( + 'template:two_column|name', "Two Column" + ), + description=_p( + 'template:two_column|desc', + "A compact two column role layout. Excludes prices and durations." + ) +) +async def twocolumn_template(menu) -> MessageArgs: + menuroles = menu.roles + + count = len(menuroles) + split_at = count // 2 + + blocks = (menuroles[:split_at], menuroles[split_at:]) + + embed = discord.Embed( + title=menu.config.name.value, + colour=discord.Colour.orange() + ) + for block in blocks: + block_lines = [ + f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}" + for menurole in block + ] + if block_lines: + embed.add_field( + name='', + value='\n'.join(block_lines) + ) + return MessageArgs(embed=embed) + + +@register_template( + id=2, + name=_p( + 'template:three_column|name', "Three Column" + ), + description=_p( + 'template:three_column|desc', + "A compact three column layout using emojis and labels, excluding prices and durations." + ) +) +async def threecolumn_template(menu) -> MessageArgs: + menuroles = menu.roles + + count = len(menuroles) + split_at = count // 3 + if count % 3 == 2: + split_at += 1 + + blocks = (menuroles[:split_at], menuroles[split_at:2*split_at], menuroles[2*split_at:]) + + embed = discord.Embed( + title=menu.config.name.value, + colour=discord.Colour.orange() + ) + for block in blocks: + block_lines = [ + f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}" + for menurole in block + ] + if block_lines: + embed.add_field( + name='', + value='\n'.join(block_lines) + ) + return MessageArgs(embed=embed) + + +@register_template( + id=3, + name=_p( + 'template:shop|name', "Role Shop" + ), + description=_p( + 'template:shop|desc', + "A single column display suitable for simple role shops" + ) +) +async def shop_template(menu) -> MessageArgs: + menuroles = menu.roles + width = max(len(str(menurole.config.price.data)) for menurole in menuroles) + + lines = [] + for menurole in menuroles: + parts = [] + emoji = menurole.config.emoji + role = menurole.config.role + price = menurole.config.price + duration = menurole.config.duration + + parts.append(f"`{price.value:>{width}} LC`") + parts.append("|") + + if emoji.data: + parts.append(emoji.formatted) + + parts.append(role.formatted) + + if duration.data: + parts.append(f"({duration.formatted})") + + lines.append(' '.join(parts)) + + description = '\n'.join(lines) + + embed = discord.Embed( + title=menu.config.name.value, + description=description, + colour=discord.Colour.orange() + ) + return MessageArgs(embed=embed) diff --git a/src/modules/rolemenus/ui/menu.py b/src/modules/rolemenus/ui/menu.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/rolemenus/ui/menueditor.py b/src/modules/rolemenus/ui/menueditor.py new file mode 100644 index 00000000..623f7981 --- /dev/null +++ b/src/modules/rolemenus/ui/menueditor.py @@ -0,0 +1,745 @@ +import asyncio +import json +from typing import Optional +from enum import Enum + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, RoleSelect, ChannelSelect, SelectOption + +from meta import LionBot, conf +from meta.errors import UserInputError +from utils.lib import utc_now, MessageArgs, error_embed +from utils.ui import MessageUI, ConfigEditor, FastModal, error_handler_for, ModalRetryUI, MsgEditor +from babel.translator import ctx_locale +from wards import equippable_role + +from .. import babel +from ..data import MenuType, RoleMenuData +from ..rolemenu import RoleMenu, RoleMenuRole +from ..menuoptions import RoleMenuOptions +from ..templates import templates + +_p = babel._p + + +class RoleEditorInput(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 EditorMode(Enum): + OPTIONS = 0 + ROLES = 1 + STYLE = 2 + + +class MenuEditor(MessageUI): + def _init_children(self): + # HACK to stop ViewWeights complaining that this UI has too many children + # Children will be correctly initialised after parent init. + return [] + + def __init__(self, bot: LionBot, menu: RoleMenu, **kwargs): + super().__init__(**kwargs) + self._children = super()._init_children() + + self.bot = bot + self.menu = menu + self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data + + # UI State + self.mode: EditorMode = EditorMode.ROLES + self.pagen: int = 0 + self._preview: Optional[discord.Interaction] = None + + # ----- UI API ----- + async def dispatch_update(self): + """ + Broadcast that the menu has changed. + + This updates the preview, and tells the menu itself to update any linked messages. + """ + await self.menu.reload() + if self._preview is not None: + args = await self._preview_args() + try: + await self._preview.edit_original_response(**args.edit_args) + except discord.NotFound: + self._preview = None + + async def _preview_args(self): + if (tid := self.menu.data.templateid) is not None: + # Apply template + template = templates[tid] + args = await template.render_menu(self.menu) + else: + raw = self.menu.data.rawmessage + data = json.loads(raw) + args = MessageArgs( + content=data.get('content', ''), + embed=discord.Embed.from_dict(data['embed']) + ) + return args + + # ----- Components ----- + # -- Options Components -- + # Menu Options Button + @button(label="OPTIONS_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def options_button(self, press: discord.Interaction, pressed: Button): + """ + Change mode to 'Options'. + """ + await press.response.defer() + self.mode = EditorMode.OPTIONS + await self.refresh() + + async def options_button_refresh(self): + t = self.bot.translator.t + button = self.options_button + button.label = t(_p( + 'ui:menu_editor|button:options|label', + "Menu Options" + )) + if self.mode is EditorMode.OPTIONS: + button.style = ButtonStyle.blurple + else: + button.style = ButtonStyle.grey + + # Bulk Edit Button + @button(label="BULK_EDIT_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def bulk_edit_button(self, press: discord.Interaction, pressed: Button): + """ + Open a Config-like modal to textually edit the Menu options. + """ + t = self.bot.translator.t + instances = ( + self.menu.config.name, + self.menu.config.sticky, + self.menu.config.refunds, + self.menu.config.obtainable, + self.menu.config.required_role, + ) + fields = [instance.input_field for instance in instances] + fields = [field for field in fields if fields] + originals = [field.value for field in fields] + modal = ConfigEditor( + *fields, + title=t(_p( + 'ui:menu_editor|button:bulk_edit|modal|title', + "Menu Options" + )) + ) + + @modal.submit_callback() + async def save_options(interaction: discord.Interaction): + modified = [] + for instance, field, original in zip(instances, fields, originals): + if field.value != original: + # Option was modified, attempt to parse + userstr = field.value.strip() + if not userstr: + new_data = None + else: + new_data = await instance._parse_string(instance.parent_id, userstr) + instance.data = new_data + modified.append(instance) + if modified: + # All fields have been parsed, it is safe to respond + await interaction.response.defer(thinking=True, ephemeral=True) + # Write settings + for instance in modified: + await instance.write() + # Propagate an update + await self.dispatch_update() + # Refresh the UI + await self.refresh(thinking=interaction) + else: + # Nothing was modified, quietly accept + await interaction.response.defer(thinking=False) + + await press.response.send_modal(modal) + + async def bulk_edit_button_refresh(self): + t = self.bot.translator.t + button = self.bulk_edit_button + button.label = t(_p( + 'ui:menu_editor|button:bulk_edit|label', + "Bulk Edit" + )) + + # Toggle Sticky Button + @button(label="STICKY_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def sticky_button(self, press: discord.Interaction, pressed: Button): + """ + Toggle the menu.config.sticky flag. + """ + await press.response.defer(thinking=True, ephemeral=True) + instance = self.menu.config.sticky + instance.value = not instance.value + await instance.write() + await self.dispatch_update() + await self.refresh(thinking=press) + + async def sticky_button_refresh(self): + t = self.bot.translator.t + button = self.sticky_button + button.label = t(_p( + 'ui:menu_editor|button:sticky|label', + "Toggle Sticky" + )) + if self.menu.config.sticky.value: + button.style = ButtonStyle.blurple + else: + button.style = ButtonStyle.grey + + # Toggle Refunds Button + @button(label="REFUNDS_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def refunds_button(self, press: discord.Interaction, pressed: Button): + """ + Toggle the menu.config.refunds flag. + """ + await press.response.defer(thinking=True, ephemeral=True) + instance = self.menu.config.refunds + instance.value = not instance.value + await instance.write() + await self.dispatch_update() + await self.refresh(thinking=press) + + async def refunds_button_refresh(self): + t = self.bot.translator.t + button = self.refunds_button + button.label = t(_p( + 'ui:menu_editor|button:refunds|label', + "Refunds" + )) + if self.menu.config.refunds.value: + button.style = ButtonStyle.blurple + else: + button.style = ButtonStyle.grey + + # Required Roles Menu + @select(cls=RoleSelect, placeholder="REQROLES_MENU_PLACEHOLDER", min_values=0, max_values=1) + async def reqroles_menu(self, selection: discord.Interaction, selected: RoleSelect): + """ + Set or reset the required role option for this menu. + """ + await selection.response.defer(thinking=True, ephemeral=True) + + if selected.values: + new_data = selected.values[0].id + else: + new_data = None + + instance = self.menu.config.required_role + instance.data = new_data + await instance.write() + await self.dispatch_update() + await self.refresh(thinking=selection) + + async def reqroles_menu_refresh(self): + t = self.bot.translator.t + menu = self.reqroles_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:reqroles|placeholder', + "Select Required Role" + )) + + # -- Roles Components -- + # Modify Roles Button + @button(label="MODIFY_ROLES_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def modify_roles_button(self, press: discord.Interaction, pressed: Button): + """ + Change mode to 'Roles'. + """ + await press.response.defer() + self.mode = EditorMode.ROLES + await self.refresh() + + async def modify_roles_button_refresh(self): + t = self.bot.translator.t + button = self.modify_roles_button + button.label = t(_p( + 'ui:menu_editor|button:modify_roles|label', + "Modify Roles" + )) + if self.mode is EditorMode.ROLES: + button.style = ButtonStyle.blurple + else: + button.style = ButtonStyle.grey + + async def _edit_menu_role(self, interaction: discord.Interaction, menurole: RoleMenuRole): + """ + Handle edit flow for the given RoleMenuRole. + + Opens the modal editor, and upon submit, also opens the RoleEditor. + """ + t = self.bot.translator.t + config = menurole.config + instances = ( + config.label, + config.emoji, + config.description, + config.price, + config.duration, + ) + fields = [instance.input_field for instance in instances] + fields = [field for field in fields if fields] + originals = [field.value for field in fields] + modal = ConfigEditor( + *fields, + title=t(_p( + 'ui:menu_editor|role_editor|modal|title', + "Edit Menu Role" + )) + ) + + @modal.submit_callback() + async def save_options(interaction: discord.Interaction): + modified = [] + for instance, field, original in zip(instances, fields, originals): + if field.value != original: + # Option was modified, attempt to parse + userstr = field.value.strip() + if not userstr: + new_data = None + else: + new_data = await instance._parse_string(instance.parent_id, userstr) + instance.data = new_data + modified.append(instance) + if modified: + # All fields have been parsed, it is safe to respond + await interaction.response.defer(thinking=True, ephemeral=True) + # Write settings + for instance in modified: + await instance.write() + # Propagate an update + await self.dispatch_update() + # Refresh the UI + await self.refresh(thinking=interaction) + else: + # Nothing was modified, quietly accept + await interaction.response.defer(thinking=False) + + await interaction.response.send_modal(modal) + await self.dispatch_update() + + # Add Roles Menu + @select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25) + async def add_roles_menu(self, selection: discord.Interaction, selected: RoleSelect): + """ + Add one or multiple roles to the menu. + + Behaviour is slightly different between one or multiple roles. + For one role, if it already exists then it is edited. If it doesn't exist + then it is added and an editor opened for it. + For multiple roles, they are ORed with the existing roles, + and no prompt is given for the fields. + """ + roles = selected.values + if len(roles) == 0: + await selection.response.defer(thinking=False) + else: + # Check equipment validity and permissions + for role in roles: + await equippable_role(self.bot, role, selection.user) + + single = None + to_create = {role.id: role for role in roles} + for mrole in self.menu.roles: + if to_create.pop(mrole.data.roleid, None) is not None: + single = mrole + + if to_create: + t = self.bot.translator.t + # Check numbers + if self.menu.data.menutype is MenuType.REACTION and len(self.menu.roles) + len(to_create) > 20: + raise UserInputError(t(_p( + 'ui:menu_editor|menu:add_roles|error:too_many_reactions', + "Too many roles! Reaction role menus cannot exceed `20` roles." + ))) + if len(self.menu.roles) + len(to_create) > 25: + raise UserInputError(t(_p( + 'ui:menu_editor|menu:add_roles|error:too_many_roles', + "Too many roles! Role menus cannot have more than `25` roles." + ))) + + # Create roles + # TODO: Emoji generation + rows = await self.data.RoleMenuRole.table.insert_many( + ('menuid', 'roleid', 'label'), + *((self.menu.data.menuid, role.id, role.name[:100]) for role in to_create.values()) + ).with_adapter(self.data.RoleMenuRole._make_rows) + mroles = [RoleMenuRole(self.bot, row) for row in rows] + single = single if single is not None else mroles[0] + await self.dispatch_update() + + if len(roles) == 1: + await self._edit_menu_role(selection, single) + await self.refresh() + else: + await selection.response.defer() + await self.refresh() + + async def add_roles_menu_refresh(self): + t = self.bot.translator.t + menu = self.add_roles_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:add_roles|placeholder', + "Add Roles" + )) + + def _role_option(self, menurole: RoleMenuRole): + return SelectOption( + label=menurole.config.label.value, + value=str(menurole.data.menuroleid), + description=menurole.config.description.value, + ) + + # Edit Roles Menu + @select(cls=Select, placeholder="EDIT_ROLES_MENU_PLACEHOLDER", min_values=1, max_values=1) + async def edit_roles_menu(self, selection: discord.Interaction, selected: Select): + """ + Edit a single selected role. + """ + menuroleid = int(selected.values[0]) + menurole = next(menurole for menurole in self.menu.roles if menurole.data.menuroleid == menuroleid) + await self._edit_menu_role(selection, menurole) + + async def edit_roles_menu_refresh(self): + t = self.bot.translator.t + menu = self.edit_roles_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:edit_roles|placeholder', + "Edit Roles" + )) + options = [self._role_option(menurole) for menurole in self.menu.roles] + if options: + menu.options = options + menu.disabled = False + else: + menu.options = [SelectOption(label='DUMMY')] + menu.disabled = True + + # Delete Roles Menu + @select(cls=Select, placeholder="DEL_ROLE_MENU_PLACEHOLDER", min_values=0, max_values=25) + async def del_role_menu(self, selection: discord.Interaction, selected: Select): + """ + Remove one or multiple menu roles. + """ + menuroleids = list(map(int, selected.values)) + if menuroleids: + await selection.response.defer(thinking=True, ephemeral=True) + await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids) + await self.dispatch_update() + else: + await selection.response.defer(thinking=False) + + async def del_role_menu_refresh(self): + t = self.bot.translator.t + menu = self.del_role_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:del_role|placeholder', + "Remove Roles" + )) + options = [self._role_option(menurole) for menurole in self.menu.roles] + if options: + menu.options = options + menu.disabled = False + else: + menu.options = [SelectOption(label='DUMMY')] + menu.disabled = True + menu.max_values = len(menu.options) + + # -- Style Components -- + # Menu Style Button + @button(label="STYLE_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def style_button(self, press: discord.Interaction, pressed: Button): + """ + Change mode to 'Style'. + """ + if self.menu.message and self.menu.message.author != self.menu.message.guild.me: + t = self.bot.translator.t + # Non-managed message, cannot change style + raise UserInputError( + t(_p( + 'ui:menu_editor|button:style|error:non-managed', + "Cannot change the style of a menu attached to a message I did not send! Please RePost first." + )) + ) + + await press.response.defer() + self.mode = EditorMode.STYLE + await self.refresh() + + async def style_button_refresh(self): + t = self.bot.translator.t + button = self.style_button + button.label = t(_p( + 'ui:menu_editor|button:style|label', + "Menu Style" + )) + if self.mode is EditorMode.STYLE: + button.style = ButtonStyle.blurple + else: + button.style = ButtonStyle.grey + + # Style Menu + @select(cls=Select, placeholder="STYLE_MENU_PLACEHOLDER", min_values=1, max_values=1) + async def style_menu(self, selection: discord.Interaction, selected: Select): + """ + Select one of Reaction Roles / Dropdown / Button + """ + t = self.bot.translator.t + value = int(selected.values[0]) + menutype = MenuType(value) + if menutype is not self.menu.data.menutype: + # A change is requested + if menutype is MenuType.REACTION: + # Some checks need to be done when moving to reaction roles + menuroles = self.menu.roles + if len(menuroles) > 20: + raise UserInputError( + t(_p( + 'ui:menu_editor|menu:style|error:too_many_reactions', + "Too many roles! The Reaction style is limited to `20` roles (Discord limitation)." + )) + ) + emojis = [mrole.config.emoji.value for mrole in menuroles] + emojis = [emoji for emoji in emojis if emoji] + uniq_emojis = set(emojis) + if len(uniq_emojis) != len(menuroles): + raise UserInputError( + t(_p( + 'ui:menu_editor|menu:style|error:incomplete_emojis', + "Cannot switch to the Reaction Role Style! Every role needs to have a distinct emoji first." + )) + ) + await selection.response.defer(thinking=True, ephemeral=True) + await self.menu.data.update(menutype=menutype) + await self.dispatch_update() + await self.refresh(thinking=selection) + else: + await selection.response.defer() + + async def style_menu_refresh(self): + t = self.bot.translator.t + menu = self.style_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:style|placeholder', + "Select Menu Style" + )) + menu.options = [ + SelectOption( + label=t(_p('ui:menu_editor|menu:style|option:reaction|label', "Reaction Roles")), + description=t(_p( + 'ui:menu_editor|menu:style|option:reaction|desc', + "Roles are represented compactly as clickable reactions on a message." + )), + value=str(MenuType.REACTION.value), + default=(self.menu.data.menutype is MenuType.REACTION) + ), + SelectOption( + label=t(_p('ui:menu_editor|menu:style|option:button|label', "Button Menu")), + description=t(_p( + 'ui:menu_editor|menu:style|option:button|desc', + "Roles are represented in 5 rows of 5 buttons, each with an emoji and label." + )), + value=str(MenuType.BUTTON.value), + default=(self.menu.data.menutype is MenuType.BUTTON) + ), + SelectOption( + label=t(_p('ui:menu_editor|menu:style|option:dropdown|label', "Dropdown Menu")), + description=t(_p( + 'ui:menu_editor|menu:style|option:dropdown|desc', + "Roles are selectable from a dropdown menu below the message." + )), + value=str(MenuType.DROPDOWN.value), + default=(self.menu.data.menutype is MenuType.DROPDOWN) + ) + ] + + async def _editor_callback(self, new_data): + raws = json.dumps(new_data) + await self.menu.data.update(rawmessage=raws) + await self.dispatch_update() + + async def _message_editor(self, interaction: discord.Interaction): + # Spawn the message editor with the current rawmessage data. + editor = MsgEditor( + self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid + ) + self._slaves.append(editor) + await editor.run(interaction) + + # Template/Custom Menu + @select(cls=Select, placeholder="TEMPLATE_MENU_PLACEHOLDER", min_values=1, max_values=1) + async def template_menu(self, selection: discord.Interaction, selected: Select): + """ + Select a template for the menu message, or create a custom message. + + If the custom message does not already exist, it will be based on the current template. + """ + templateid = int(selected.values[0]) + if templateid != self.menu.data.templateid: + # Changes requested + await selection.response.defer(thinking=True, ephemeral=True) + if templateid == -1: + # Chosen a custom message + # Initialise the custom message if needed + update_args = {'templateid': None} + if not self.menu.data.rawmessage: + template = templates[self.menu.data.templateid] + margs = await template.render_menu(self.menu) + raw = { + 'content': margs.kwargs.get('content', ''), + } + if 'embed' in margs.kwargs: + raw['embed'] = margs.kwargs['embed'].to_dict() + rawjson = json.dumps(raw) + update_args['rawmessage'] = rawjson + + # Save choice to data + await self.menu.data.update(**update_args) + + # Spawn editor + await self._message_editor(selection) + await self.dispatch_update() + await self.refresh() + else: + await self.menu.data.update(templateid=templateid) + await self.dispatch_update() + await self.refresh(thinking=selection) + else: + await selection.response.defer() + + async def template_menu_refresh(self): + t = self.bot.translator.t + menu = self.template_menu + menu.placeholder = t(_p( + 'ui:menu_editor|menu:template|placeholder', + "Select Message Template" + )) + options = [] + for template in templates.values(): + option = template.as_option() + option.default = (self.menu.data.templateid == template.id) + options.append(option) + custom_option = SelectOption( + label=t(_p( + 'ui:menu_editor|menu:template|option:custom|label', + "Custom Message" + )), + value='-1', + description=t(_p( + 'ui:menu_editor|menu:template|option:custom|description', + "Entirely custom menu message (opens an interactive editor)." + )), + default=(self.menu.data.templateid is None) + ) + options.append(custom_option) + menu.options = options + + # -- Common Components -- + # Delete Menu Button + # Quit Button + + # Page left Button + # Edit Message Button + # Preview Button + @button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def preview_button(self, press: discord.Interaction, pressed: Button): + """ + Display or update the preview message. + """ + args = await self._preview_args() + if self._preview is not None: + try: + await self._preview.delete_original_response() + except discord.HTTPException: + pass + self._preview = None + await press.response.send_message(**args.send_args, ephemeral=True) + self._preview = press + + async def preview_button_refresh(self): + t = self.bot.translator.t + button = self.preview_button + button.label = t(_p( + 'ui:menu_editor|button:preview|label', + "Preview" + )) + + # Repost Menu Button + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + + title = t(_p( + 'ui:menu_editor|embed|title', + "'{name}' Role Menu Editor" + )).format(name=self.menu.config.name.value) + + table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid) + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title, + description=table + ) + return MessageArgs(embed=embed) + + async def refresh_layout(self): + to_refresh = ( + self.options_button_refresh(), + self.reqroles_menu_refresh(), + self.sticky_button_refresh(), + self.refunds_button_refresh(), + self.bulk_edit_button_refresh(), + self.modify_roles_button_refresh(), + self.add_roles_menu_refresh(), + self.edit_roles_menu_refresh(), + self.del_role_menu_refresh(), + self.style_button_refresh(), + self.style_menu_refresh(), + self.template_menu_refresh(), + self.preview_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + line_1 = ( + self.options_button, self.modify_roles_button, self.style_button, + ) + line_last = ( + self.preview_button, + ) + if self.mode is EditorMode.OPTIONS: + self.set_layout( + line_1, + (self.bulk_edit_button, self.sticky_button, self.refunds_button,), + (self.reqroles_menu,), + line_last, + ) + elif self.mode is EditorMode.ROLES: + self.set_layout( + line_1, + (self.add_roles_menu,), + (self.edit_roles_menu,), + (self.del_role_menu,), + line_last + ) + elif self.mode is EditorMode.STYLE: + self.set_layout( + line_1, + (self.style_menu,), + (self.template_menu,), + line_last + ) + + async def reload(self): + ... diff --git a/src/modules/rolemenus/ui/menus.py b/src/modules/rolemenus/ui/menus.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/rolemenus/ui/msgeditor.py b/src/modules/rolemenus/ui/msgeditor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/rolemenus/ui/roleeditor.py b/src/modules/rolemenus/ui/roleeditor.py new file mode 100644 index 00000000..e69de29b diff --git a/src/utils/ansi.py b/src/utils/ansi.py new file mode 100644 index 00000000..11f28527 --- /dev/null +++ b/src/utils/ansi.py @@ -0,0 +1,97 @@ +""" +Minimal library for making Discord Ansi colour codes. +""" +from enum import StrEnum + + +PREFIX = u'\u001b' + + +class TextColour(StrEnum): + Gray = '30' + Red = '31' + Green = '32' + Yellow = '33' + Blue = '34' + Pink = '35' + Cyan = '36' + White = '37' + + def __str__(self) -> str: + return AnsiColour(fg=self).as_str() + + def __call__(self): + return AnsiColour(fg=self) + + +class BgColour(StrEnum): + FireflyDarkBlue = '40' + Orange = '41' + MarbleBlue = '42' + GrayTurq = '43' + Gray = '44' + Indigo = '45' + LightGray = '46' + White = '47' + + def __str__(self) -> str: + return AnsiColour(bg=self).as_str() + + def __call__(self): + return AnsiColour(bg=self) + + +class Format(StrEnum): + NORMAL = '0' + BOLD = '1' + UNDERLINE = '4' + NOOP = '9' + + def __str__(self) -> str: + return AnsiColour(self).as_str() + + def __call__(self): + return AnsiColour(self) + + +class AnsiColour: + def __init__(self, *flags, fg=None, bg=None): + self.text_colour = fg + self.background_colour = bg + self.reset = (Format.NORMAL in flags) + self._flags = set(flags) + self._flags.discard(Format.NORMAL) + + @property + def flags(self): + return (*((Format.NORMAL,) if self.reset else ()), *self._flags) + + def as_str(self): + parts = [] + if self.reset: + parts.append(Format.NORMAL) + elif not self.flags: + parts.append(Format.NOOP) + + parts.extend(self._flags) + + for c in (self.text_colour, self.background_colour): + if c is not None: + parts.append(c) + + partstr = ';'.join(part.value for part in parts) + return f"{PREFIX}[{partstr}m" # ] + + def __str__(self): + return self.as_str() + + def __add__(self, obj: 'AnsiColour'): + text_colour = obj.text_colour or self.text_colour + background_colour = obj.background_colour or self.background_colour + flags = (*self.flags, *obj.flags) + return AnsiColour(*flags, fg=text_colour, bg=background_colour) + + +RESET = AnsiColour(Format.NORMAL) +BOLD = AnsiColour(Format.BOLD) +UNDERLINE = AnsiColour(Format.UNDERLINE) diff --git a/src/utils/ui/__init__.py b/src/utils/ui/__init__.py index c8ad4baa..e035c421 100644 --- a/src/utils/ui/__init__.py +++ b/src/utils/ui/__init__.py @@ -10,6 +10,7 @@ from .micros import * from .pagers import * from .transformed import * from .config import * +from .msgeditor import * # def create_task_in(coro, context: Context): diff --git a/src/utils/ui/leo.py b/src/utils/ui/leo.py index ca9c9b30..2379112d 100644 --- a/src/utils/ui/leo.py +++ b/src/utils/ui/leo.py @@ -8,9 +8,10 @@ import discord from discord.ui import Modal, View, Item from meta.logger import log_action_stack, logging_context +from meta.errors import SafeCancellation from . import logger -from ..lib import MessageArgs +from ..lib import MessageArgs, error_embed __all__ = ( 'LeoUI', @@ -198,6 +199,25 @@ class LeoUI(View): """ try: raise error + except SafeCancellation as e: + if e.msg and not interaction.is_expired(): + try: + if interaction.response.is_done(): + await interaction.followup.send( + embed=error_embed(e.msg), + ephemeral=True + ) + else: + await interaction.response.send_message( + embed=error_embed(e.msg), + ephemeral=True + ) + except discord.HTTPException: + pass + logger.debug( + f"Caught a safe cancellation from LeoUI: {e.details}", + extra={'action': 'Cancel'} + ) except Exception: logger.exception( f"Unhandled interaction exception occurred in item {item!r} of LeoUI {self!r}", diff --git a/src/utils/ui/micros.py b/src/utils/ui/micros.py index 31c7cf17..e02e5d24 100644 --- a/src/utils/ui/micros.py +++ b/src/utils/ui/micros.py @@ -160,6 +160,7 @@ class ModalRetryUI(LeoUI): @property def embed(self): return discord.Embed( + title="Uh-Oh!", description=self.message, colour=discord.Colour.red() ) diff --git a/src/utils/ui/msgeditor.py b/src/utils/ui/msgeditor.py new file mode 100644 index 00000000..62c0e1dc --- /dev/null +++ b/src/utils/ui/msgeditor.py @@ -0,0 +1,1022 @@ +from typing import Optional +import asyncio +import copy +import json +import datetime as dt +from io import StringIO + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, SelectOption +from discord.ui.text_input import TextInput, TextStyle + +from meta import conf, LionBot +from meta.errors import UserInputError, ResponseTimedOut +from utils.lib import error_embed +from babel.translator import ctx_translator, LazyStr + +from ..lib import MessageArgs, utc_now + +from . import MessageUI, util_babel, error_handler_for, FastModal, ModalRetryUI, Confirm, AsComponents, AButton + + +_p = util_babel._p + + +class MsgEditorInput(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 MsgEditor(MessageUI): + def __init__(self, bot: LionBot, initial_data: dict, formatter=None, callback=None, **kwargs): + self.bot = bot + self.history = [initial_data] # Last item in history is current state + self.future = [] # Last item in future is next state + + self._formatter = formatter + self._callback = callback + + super().__init__(**kwargs) + + @property + def data(self): + return self.history[-1] + + # ----- API ----- + async def format_data(self, data): + if self._formatter is not None: + self._formatter(data) + + def copy_data(self): + return copy.deepcopy(self.history[-1]) + + async def save(self): + ... + + async def push_change(self, new_data): + # Cleanup the data + if (embed_data := new_data.get('embed', None)) is not None and not embed_data: + new_data.pop('embed') + + t = self.bot.translator.t + if 'embed' not in new_data and not new_data.get('content', None): + raise UserInputError( + t(_p( + 'ui:msg_editor|error:empty', + "Rendering failed! The message content and embed cannot both be empty." + )) + ) + + if 'embed' in new_data: + try: + discord.Embed.from_dict(new_data['embed']) + except Exception as e: + raise UserInputError( + t(_p( + 'ui:msg_editor|error:embed_failed', + "Rendering failed! Could not parse the embed.\n" + "Error: {error}" + )).format(error=str(e)) + ) + + # Push the state and try displaying it + self.history.append(new_data) + old_future = self.future + self.future = [] + try: + await self.refresh() + except discord.HTTPException as e: + # State failed, rollback and error + self.history.pop() + self.future = old_future + raise UserInputError( + t(_p( + 'ui:msg_editor|error:invalid_change', + "Rendering failed! The message was not modified.\n" + "Error: `{text}`" + )).format(text=e.text) + ) + + # ----- UI Components ----- + + # -- Content Only mode -- + @button(label="EDIT_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_button(self, press: discord.Interaction, pressed: Button): + """ + Open an editor for the message content + """ + data = self.copy_data() + + t = self.bot.translator.t + content_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:content|field:content|label', + "Message Content" + )), + style=TextStyle.long, + required=False, + default=data.get('content', ""), + max_length=2000 + ) + modal = MsgEditorInput( + content_field, + title=t(_p('ui:msg_editor|modal:content|title', "Content Editor")) + ) + + @modal.submit_callback() + async def content_modal_callback(interaction: discord.Interaction): + new_content = content_field.value + data['content'] = new_content + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def edit_button_refresh(self): + t = self.bot.translator.t + button = self.edit_button + button.label = t(_p( + 'ui:msg_editor|button:edit|label', + "Edit Content" + )) + + @button(label="ADD_EMBED_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def add_embed_button(self, press: discord.Interaction, pressed: Button): + """ + Attach an embed with some simple fields filled. + """ + await press.response.defer() + t = self.bot.translator.t + + sample_embed = { + "title": t(_p('ui:msg_editor|button:add_embed|sample_embed|title', "Title Placeholder")), + "description": t(_p('ui:msg_editor|button:add_embed|sample_embed|description', "Description Placeholder")), + } + data = self.copy_data() + data['embed'] = sample_embed + await self.push_change(data) + + async def add_embed_button_refresh(self): + t = self.bot.translator.t + button = self.add_embed_button + button.label = t(_p( + 'ui:msg_editor|button:add_embed|label', + "Add Embed" + )) + + # -- Embed Mode -- + + @button(label="BODY_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def body_button(self, press: discord.Interaction, pressed: Button): + """ + Edit the Content, Description, Title, and Colour + """ + data = self.copy_data() + embed_data = data.get('embed', {}) + + t = self.bot.translator.t + + content_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:body|field:content|label', + "Message Content" + )), + style=TextStyle.long, + required=False, + default=data.get('content', ""), + max_length=2000 + ) + + desc_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:body|field:desc|label', + "Embed Description" + )), + style=TextStyle.long, + required=False, + default=embed_data.get('description', ""), + max_length=4000 + ) + + title_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:body|field:title|label', + "Embed Title" + )), + style=TextStyle.short, + required=False, + default=embed_data.get('title', ""), + max_length=256 + ) + + colour_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:body|field:colour|label', + "Embed Colour" + )), + style=TextStyle.short, + required=False, + default=str(discord.Colour(value=embed_data['color'])) if 'color' in embed_data else '', + placeholder=str(discord.Colour.orange()), + max_length=7, + min_length=7 + ) + + modal = MsgEditorInput( + content_field, + title_field, + desc_field, + colour_field, + title=t(_p('ui:msg_editor|modal:body|title', "Message Body Editor")) + ) + + @modal.submit_callback() + async def body_modal_callback(interaction: discord.Interaction): + data['content'] = content_field.value + + if desc_field.value: + embed_data['description'] = desc_field.value + else: + embed_data.pop('description', None) + + if title_field.value: + embed_data['title'] = title_field.value + else: + embed_data.pop('title', None) + + if colour_field.value: + colourstr = colour_field.value + try: + colour = discord.Colour.from_str(colourstr) + except ValueError: + raise UserInputError( + t(_p( + 'ui:msg_editor|button:body|error:invalid_colour', + "Invalid colour format! Please enter colours as hex codes, e.g. `#E67E22`" + )) + ) + embed_data['color'] = colour.value + else: + embed_data.pop('color', None) + + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def body_button_refresh(self): + t = self.bot.translator.t + button = self.body_button + button.label = t(_p( + 'ui:msg_editor|button:body|label', + "Body" + )) + + @button(label="AUTHOR_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def author_button(self, press: discord.Interaction, pressed: Button): + """ + Edit the embed author (author name/link/image url) + """ + data = self.copy_data() + embed_data = data.get('embed', {}) + author_data = embed_data.get('author', {}) + + t = self.bot.translator.t + + name_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:author|field:name|label', + "Author Name" + )), + style=TextStyle.short, + required=False, + default=author_data.get('name', ''), + max_length=256 + ) + + link_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:author|field:link|label', + "Author URL" + )), + style=TextStyle.short, + required=False, + default=author_data.get('url', ''), + ) + + image_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:author|field:image|label', + "Author Image URL" + )), + style=TextStyle.short, + required=False, + default=author_data.get('icon_url', ''), + ) + + modal = MsgEditorInput( + name_field, + link_field, + image_field, + title=t(_p('ui:msg_editor|modal:author|title', "Embed Author Editor")) + ) + + @modal.submit_callback() + async def author_modal_callback(interaction: discord.Interaction): + if (name := name_field.value): + author_data['name'] = name + author_data['icon_url'] = image_field.value + author_data['url'] = link_field.value + embed_data['author'] = author_data + else: + embed_data.pop('author', None) + + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def author_button_refresh(self): + t = self.bot.translator.t + button = self.author_button + button.label = t(_p( + 'ui:msg_editor|button:author|label', + "Author" + )) + + @button(label="FOOTER_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def footer_button(self, press: discord.Interaction, pressed: Button): + """ + Open the Footer editor (edit footer icon, text, timestamp). + """ + data = self.copy_data() + embed_data = data.get('embed', {}) + footer_data = embed_data.get('footer', {}) + + t = self.bot.translator.t + + text_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:footer|field:text|label', + "Footer Text" + )), + style=TextStyle.long, + required=False, + default=footer_data.get('text', ''), + max_length=2048 + ) + + image_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:footer|field:image|label', + "Footer Image URL" + )), + style=TextStyle.short, + required=False, + default=footer_data.get('icon_url', ''), + ) + + timestamp_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:footer|field:timestamp|label', + "Embed Timestamp (in ISO format)" + )), + style=TextStyle.short, + required=False, + default=embed_data.get('timestamp', ''), + placeholder=utc_now().replace(microsecond=0).isoformat(sep=' ') + ) + + modal = MsgEditorInput( + text_field, + image_field, + timestamp_field, + title=t(_p('ui:msg_editor|modal:footer|title', "Embed Footer Editor")) + ) + + @modal.submit_callback() + async def footer_modal_callback(interaction: discord.Interaction): + if (text := text_field.value): + footer_data['text'] = text + footer_data['icon_url'] = image_field.value + embed_data['footer'] = footer_data + else: + embed_data.pop('footer', None) + + if (ts := timestamp_field.value): + try: + dt.datetime.fromisoformat(ts) + except ValueError: + raise UserInputError( + t(_p( + 'ui:msg_editor|button:footer|error:invalid_timestamp', + "Invalid timestamp! Please enter the timestamp in ISO format." + )) + ) + embed_data['timestamp'] = ts + else: + embed_data.pop('timestamp', None) + + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def footer_button_refresh(self): + t = self.bot.translator.t + button = self.footer_button + button.label = t(_p( + 'ui:msg_editor|button:footer|label', + "Footer" + )) + + @button(label="IMAGES_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def images_button(self, press: discord.Interaction, pressed: Button): + """ + Edit the embed images (thumbnail and main image). + """ + data = self.copy_data() + embed_data = data.get('embed', {}) + thumb_data = embed_data.get('thumbnail', {}) + image_data = embed_data.get('image', {}) + + t = self.bot.translator.t + + thumb_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:images|field:thumb|label', + "Thumbnail Image URL" + )), + style=TextStyle.short, + required=False, + default=thumb_data.get('url', ''), + ) + + image_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:images|field:image|label', + "Embed Image URL" + )), + style=TextStyle.short, + required=False, + default=image_data.get('url', ''), + ) + + modal = MsgEditorInput( + thumb_field, + image_field, + title=t(_p('ui:msg_editor|modal:images|title', "Embed images Editor")) + ) + + @modal.submit_callback() + async def images_modal_callback(interaction: discord.Interaction): + if (thumb_url := thumb_field.value): + thumb_data['url'] = thumb_url + embed_data['thumbnail'] = thumb_data + else: + embed_data.pop('thumbnail', None) + + if (image_url := image_field.value): + image_data['url'] = image_url + embed_data['image'] = image_data + else: + embed_data.pop('image', None) + + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def images_button_refresh(self): + t = self.bot.translator.t + button = self.images_button + button.label = t(_p( + 'ui:msg_editor|button:images|label', + "Images" + )) + + @button(label="ADD_FIELD_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def add_field_button(self, press: discord.Interaction, pressed: Button): + """ + Add an embed field (position, name, value, inline) + """ + data = self.copy_data() + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + orig_fields = field_data.copy() + + t = self.bot.translator.t + + position_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:add_field|field:position|label', + "Field number to insert at" + )), + style=TextStyle.short, + required=True, + default=str(len(field_data)), + ) + + name_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:add_field|field:name|label', + "Field name" + )), + style=TextStyle.short, + required=False, + max_length=256, + ) + + value_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:add_field|field:value|label', + "Field value" + )), + style=TextStyle.long, + required=True, + max_length=1024, + ) + + inline_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:add_field|field:inline|label', + "Whether the field is inline" + )), + placeholder=t(_p( + 'ui:msg_editor|modal:add_field|field:inline|placeholder', + "True/False" + )), + style=TextStyle.short, + required=True, + max_length=256, + ) + + modal = MsgEditorInput( + position_field, + name_field, + value_field, + inline_field, + title=t(_p('ui:msg_editor|modal:add_field|title', "Add Embed Field")) + ) + + @modal.submit_callback() + async def add_field_modal_callback(interaction: discord.Interaction): + if inline_field.value.lower() == 'true': + inline = True + else: + inline = False + field = { + 'name': name_field.value, + 'value': value_field.value, + 'inline': inline + } + try: + position = int(position_field.value) + except ValueError: + raise UserInputError( + t(_p( + 'ui:msg_editor|modal:add_field|error:position_not_int', + "The field position must be an integer!" + )) + ) + field_data = orig_fields.copy() + field_data.insert(position, field) + embed_data['fields'] = field_data + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await press.response.send_modal(modal) + + async def add_field_button_refresh(self): + t = self.bot.translator.t + button = self.add_field_button + button.label = t(_p( + 'ui:msg_editor|button:add_field|label', + "Add Field" + )) + data = self.history[-1] + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + button.disabled = (len(field_data) >= 25) + + def _field_option(self, index, field_data): + t = self.bot.translator.t + + name = field_data.get('name', "") + value = field_data['value'] + + if not name: + name = t(_p( + 'ui:msg_editor|format_field|name_placeholder', + "-" + )) + + name = f"{index+1}. {name}" + if len(name) > 100: + name = name[:97] + '...' + + if len(value) > 100: + value = value[:97] + '...' + + return SelectOption(label=name, description=value, value=str(index)) + + @select(cls=Select, placeholder="EDIT_FIELD_MENU_PLACEHOLDER", max_values=1) + async def edit_field_menu(self, selection: discord.Interaction, selected: Select): + if not selected.values: + await selection.response.defer() + return + + index = int(selected.values[0]) + data = self.copy_data() + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + field = field_data[index] + + t = self.bot.translator.t + + name_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:edit_field|field:name|label', + "Field name" + )), + style=TextStyle.short, + default=field.get('name', ''), + required=False, + max_length=256, + ) + + value_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:edit_field|field:value|label', + "Field value" + )), + style=TextStyle.long, + default=field.get('value', ''), + required=True, + max_length=1024, + ) + + inline_field = TextInput( + label=t(_p( + 'ui:msg_editor|modal:edit_field|field:inline|label', + "Whether the field is inline" + )), + placeholder=t(_p( + 'ui:msg_editor|modal:edit_field|field:inline|placeholder', + "True/False" + )), + default='True' if field.get('inline', True) else 'False', + style=TextStyle.short, + required=True, + max_length=256, + ) + + modal = MsgEditorInput( + name_field, + value_field, + inline_field, + title=t(_p('ui:msg_editor|modal:edit_field|title', "Edit Embed Field")) + ) + + @modal.submit_callback() + async def edit_field_modal_callback(interaction: discord.Interaction): + if inline_field.value.lower() == 'true': + inline = True + else: + inline = False + field = { + 'name': name_field.value, + 'value': value_field.value, + 'inline': inline + } + field_data[index] = field + embed_data['fields'] = field_data + data['embed'] = embed_data + + await self.push_change(data) + + await interaction.response.defer() + + await selection.response.send_modal(modal) + + async def edit_field_menu_refresh(self): + t = self.bot.translator.t + menu = self.edit_field_menu + menu.placeholder = t(_p( + 'ui:msg_editor|menu:edit_field|placeholder', + "Edit Embed Field" + )) + data = self.history[-1] + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + + if len(field_data) == 0: + menu.disabled = True + menu.options = [ + SelectOption(label='Dummy') + ] + else: + menu.disabled = False + menu.options = [ + self._field_option(i, field) + for i, field in enumerate(field_data) + ] + + @select(cls=Select, placeholder="DELETE_FIELD_MENU_PLACEHOLDER", max_values=1) + async def delete_field_menu(self, selection: discord.Interaction, selected: Select): + if not selected.values: + await selection.response.defer() + return + + index = int(selected.values[0]) + data = self.copy_data() + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + field_data.pop(index) + if not field_data: + embed_data.pop('fields') + await self.push_change(data) + await selection.response.defer() + + async def delete_field_menu_refresh(self): + t = self.bot.translator.t + menu = self.delete_field_menu + menu.placeholder = t(_p( + 'ui:msg_deleteor|menu:delete_field|placeholder', + "Remove Embed Field" + )) + data = self.history[-1] + embed_data = data.get('embed', {}) + field_data = embed_data.get('fields', []) + + if len(field_data) == 0: + menu.disabled = True + menu.options = [ + SelectOption(label='Dummy') + ] + else: + menu.disabled = False + menu.options = [ + self._field_option(i, field) + for i, field in enumerate(field_data) + ] + + # -- Shared -- + @button(label="SAVE_BUTTON_PLACEHOLDER", style=ButtonStyle.green) + async def save_button(self, press: discord.Interaction, pressed: Button): + """ + Saving simply resets the undo stack and calls the callback function. + Presumably the callback is hooked up to data or similar. + """ + await press.response.defer(thinking=True, ephemeral=True) + if self._callback is not None: + await self._callback(self.data) + self.history = self.history[-1:] + await self.refresh(thinking=press) + + async def save_button_refresh(self): + t = self.bot.translator.t + button = self.save_button + button.label = t(_p( + 'ui:msg_editor|button:save|label', + "Save" + )) + if len(self.history) > 1: + original = json.dumps(self.history[0]) + current = json.dumps(self.history[-1]) + button.disabled = (original == current) + else: + button.disabled = True + + @button(label="DOWNLOAD_BUTTON_PLACEHOLDER", style=ButtonStyle.grey) + async def download_button(self, press: discord.Interaction, pressed: Button): + """ + Reply ephemerally with a formatted json version of the message content. + """ + data = json.dumps(self.history[-1], indent=2) + with StringIO(data) as fp: + fp.seek(0) + file = discord.File(fp, filename='message.json') + await press.response.send_message(file=file, ephemeral=True) + + async def download_button_refresh(self): + t = self.bot.translator.t + button = self.download_button + button.label = t(_p( + 'ui:msg_editor|button:download|label', + "Download" + )) + + @button(label="UNDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def undo_button(self, press: discord.Interaction, pressed: Button): + """ + Pop the history stack. + """ + if len(self.history) > 1: + state = self.history.pop() + self.future.append(state) + await press.response.defer() + await self.refresh() + + async def undo_button_refresh(self): + t = self.bot.translator.t + button = self.undo_button + button.label = t(_p( + 'ui:msg_editor|button:undo|label', + "Undo" + )) + button.disabled = (len(self.history) <= 1) + + @button(label="REDO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def redo_button(self, press: discord.Interaction, pressed: Button): + """ + Pop the future stack. + """ + if len(self.future) > 0: + state = self.future.pop() + self.history.append(state) + await press.response.defer() + await self.refresh() + + async def redo_button_refresh(self): + t = self.bot.translator.t + button = self.redo_button + button.label = t(_p( + 'ui:msg_editor|button:redo|label', + "Redo" + )) + button.disabled = (len(self.future) == 0) + + @button(style=ButtonStyle.grey, emoji=conf.emojis.cancel) + async def quit_button(self, press: discord.Interaction, pressed: Button): + # Confirm quit if there are unsaved changes + unsaved = False + if len(self.history) > 1: + original = json.dumps(self.history[0]) + current = json.dumps(self.history[-1]) + if original != current: + unsaved = True + + # Confirmation prompt + if unsaved: + t = self.bot.translator.t + confirm_msg = t(_p( + 'ui:msg_editor|button:quit|confirm', + "You have unsaved changes! Are you sure you want to quit?" + )) + confirm = Confirm(confirm_msg, self._callerid) + confirm.confirm_button.label = t(_p( + 'ui:msg_editor|button:quit|confirm|button:yes', + "Yes, Quit Now" + )) + confirm.confirm_button.style = ButtonStyle.red + confirm.cancel_button.style = ButtonStyle.green + confirm.cancel_button.label = t(_p( + 'ui:msg_editor|button:quit|confirm|button:no', + "No, Go Back" + )) + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + await self.quit() + else: + await self.quit() + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + data = self.copy_data() + await self.format_data(data) + + args = {} + args['content'] = data.get('content', '') + + if 'embed' in data: + args['embed'] = discord.Embed.from_dict(data['embed']) + else: + args['embed'] = None + + return MessageArgs(**args) + + async def refresh_layout(self): + await asyncio.gather( + self.edit_button_refresh(), + self.add_embed_button_refresh(), + self.body_button_refresh(), + self.author_button_refresh(), + self.footer_button_refresh(), + self.images_button_refresh(), + self.add_field_button_refresh(), + self.edit_field_menu_refresh(), + self.delete_field_menu_refresh(), + self.save_button_refresh(), + self.download_button_refresh(), + self.undo_button_refresh(), + self.redo_button_refresh(), + ) + if self.history[-1].get('embed', None): + self.set_layout( + (self.body_button, self.author_button, self.footer_button, self.images_button, self.add_field_button), + (self.edit_field_menu,), + (self.delete_field_menu,), + (self.save_button, self.download_button, self.undo_button, self.redo_button, self.quit_button), + ) + else: + self.set_layout( + (self.edit_button, self.add_embed_button), + (self.save_button, self.download_button, self.undo_button, self.redo_button, self.quit_button), + ) + + async def reload(self): + # All data is handled by components, so nothing to do here + pass + + async def redraw(self, thinking: Optional[discord.Interaction] = None): + """ + Overriding MessageUI.redraw to propagate exception. + """ + await self.refresh_layout() + 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()) + + 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 already closed. Quietly cleanup. + await self.close() + + async def pre_timeout(self): + unsaved = False + if len(self.history) > 1: + original = json.dumps(self.history[0]) + current = json.dumps(self.history[-1]) + if original != current: + unsaved = True + + # Timeout confirmation + if unsaved: + t = self.bot.translator.t + grace_period = 60 + grace_time = utc_now() + dt.timedelta(seconds=grace_period) + embed = discord.Embed( + title=t(_p( + 'ui:msg_editor|timeout_warning|title', + "Warning!" + )), + description=t(_p( + 'ui:msg_editor|timeout_warning|desc', + "This interface will time out {timestamp}. Press 'Continue' below to keep editing." + )).format( + timestamp=discord.utils.format_dt(grace_time, style='R') + ), + ) + + components = None + stopped = False + + @AButton(label=t(_p('ui:msg_editor|timeout_warning|continue', "Continue")), style=ButtonStyle.green) + async def cont_button(interaction: discord.Interaction, pressed): + await interaction.response.defer() + await interaction.message.delete() + stopped = True + # TODO: Clean up this mess. It works, but needs to be refactored to a timeout confirmation mixin. + # TODO: Consider moving the message to the interaction response + self._refresh_timeout() + components.stop() + + components = AsComponents(cont_button, timeout=grace_period) + message = await self._original.channel.send(content=f"<@{self._callerid}>", embed=embed, view=components) + await components.wait() + + if not stopped: + try: + await message.delete() + except discord.HTTPException: + pass diff --git a/src/wards.py b/src/wards.py index ec76b7f3..d058e3b6 100644 --- a/src/wards.py +++ b/src/wards.py @@ -6,6 +6,7 @@ import discord.ext.commands as cmds from babel.translator import LocalBabel from meta import conf, LionContext, LionBot +from meta.errors import UserInputError babel = LocalBabel('wards') _p = babel._p @@ -96,3 +97,79 @@ async def low_management_ward(ctx: LionContext) -> bool: "You must have the `MANAGE_GUILD` permission in this server to do this!" )) ) + + +# ---- Assorted manual wards and checks ---- + + +async def equippable_role(bot: LionBot, target_role: discord.Role, actor: discord.Member): + """ + Validator for an 'actor' setting a given 'target_role' as obtainable. + + Checks that the 'target_role' is able to be given out, + that I am able to give it out, and that the 'actor' is able to give it out. + Raises UserInputError if any of these do not hold. + """ + t = bot.translator.t + guild = target_role.guild + me = guild.me + + if target_role.is_bot_managed(): + raise UserInputError( + t(_p( + 'ward:equippable_role|error:bot_managed', + "I cannot manage {role} because it is managed by another bot!" + )).format(role=target_role.mention) + ) + elif target_role.is_integration(): + raise UserInputError( + t(_p( + 'ward:equippable_role|error:integration', + "I cannot manage {role} because it is managed by a server integration." + )).format(role=target_role.mention) + ) + elif target_role == guild.default_role: + raise UserInputError( + t(_p( + 'ward:equippable_role|error:default_role', + "I cannot manage the server's default role." + )).format(role=target_role.mention) + ) + elif not me.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'ward:equippable_role|error:no_perms', + "I need the `MANAGE_ROLES` permission before I can manage roles!" + )).format(role=target_role.mention) + ) + elif me.top_role <= target_role: + raise UserInputError( + t(_p( + 'ward:equippable_role|error:my_top_role', + "I cannot assign or remove {role} because it is above my top role!" + )).format(role=target_role.mention) + ) + elif not target_role.is_assignable(): + raise UserInputError( + t(_p( + 'ward:equippable_role|error:not_assignable', + "I don't have sufficient permissions to assign or remove {role}." + )).format(role=target_role.mention) + ) + + if not actor.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'ward:equippable_role|error:actor_perms', + "You need the `MANAGE_ROLES` permission before you can configure roles!" + )).format(role=target_role.mention) + ) + elif actor.top_role <= target_role and not actor == guild.owner: + raise UserInputError( + t(_p( + 'ward:equippable_role|error:actor_top_role', + "You cannot configure {role} because it is above your top role!" + )).format(role=target_role.mention) + ) + + return True