diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 0b34f0d7..ab14d2be 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -928,6 +928,92 @@ INSERT INTO schedule_session_members (guildid, userid, slotid, booked_at, attend -- Drop old schema -- }}} +-- Role Menus {{{ +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, + default_price INTEGER, + event_log BOOLEAN +); +CREATE INDEX role_menu_guildid ON role_menus (guildid); + + + +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 INDEX role_menu_roles_menuid ON role_menu_roles (menuid); +CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid); + + +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, + removed_at TIMESTAMPTZ +); +CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid); +CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid); + + +-- Migration +INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype) + SELECT + messageid, guildid, channelid, enabled, + required_role, NOT removable, maximum, + refunds, event_log, default_price, messageid :: TEXT, + 'REACTION' + FROM reaction_role_messages; + +INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration) + SELECT + role_menus.menuid, reactions.roleid, reactions.roleid::TEXT, + COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name), + reactions.price, reactions.timeout + FROM reaction_role_reactions reactions + LEFT JOIN role_menus + ON role_menus.messageid = reactions.messageid; + +INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at) + SELECT + rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry + FROM reaction_role_expiring expiring + LEFT JOIN role_menu_roles rmr + ON rmr.roleid = expiring.roleid + WHERE rmr.menuid IS NOT NULL; +-- }}} + + INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); COMMIT; diff --git a/data/migration/v12-13/rolemenus.sql b/data/migration/v12-13/rolemenus.sql index 28535dfd..5ce424e4 100644 --- a/data/migration/v12-13/rolemenus.sql +++ b/data/migration/v12-13/rolemenus.sql @@ -24,8 +24,12 @@ CREATE TABLE role_menus( obtainable INTEGER, menutype RoleMenuType NOT NULL, templateid INTEGER, - rawmessage TEXT + rawmessage TEXT, + default_price INTEGER, + event_log BOOLEAN ); +CREATE INDEX role_menu_guildid ON role_menus (guildid); + CREATE TABLE role_menu_roles( @@ -39,6 +43,8 @@ CREATE TABLE role_menu_roles( duration INTEGER, rawreply TEXT ); +CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid); +CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid); CREATE TABLE role_menu_history( @@ -49,5 +55,35 @@ CREATE TABLE role_menu_history( obtained_at TIMESTAMPTZ NOT NULL, transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, expires_at TIMESTAMPTZ, - expired_at TIMESTAMPTZ + removed_at TIMESTAMPTZ ); +CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid); +CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid); + + +-- Migration +INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype) + SELECT + messageid, guildid, channelid, enabled, + required_role, NOT removable, maximum, + refunds, event_log, default_price, messageid :: TEXT, + 'REACTION' + FROM reaction_role_messages; + +INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration) + SELECT + role_menus.menuid, reactions.roleid, reactions.roleid::TEXT, + COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name), + reactions.price, reactions.timeout + FROM reaction_role_reactions reactions + LEFT JOIN role_menus + ON role_menus.messageid = reactions.messageid; + +INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at) + SELECT + rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry + FROM reaction_role_expiring expiring + LEFT JOIN role_menu_roles rmr + ON rmr.roleid = expiring.roleid + WHERE rmr.menuid IS NOT NULL; + diff --git a/src/core/lion_member.py b/src/core/lion_member.py index 7245d433..1fbb8cc6 100644 --- a/src/core/lion_member.py +++ b/src/core/lion_member.py @@ -6,6 +6,7 @@ import discord from meta import LionBot from utils.lib import Timezoned from settings.groups import ModelConfig, SettingDotDict +from babel.translator import SOURCE_LOCALE from .data import CoreData from .lion_user import LionUser @@ -63,6 +64,21 @@ class LionMember(Timezoned): guild_timezone = self.lguild.config.timezone return user_timezone.value if user_timezone._data is not None else guild_timezone.value + def private_locale(self, interaction=None) -> str: + """ + Appropriate locale to use in private communication with this member. + + Does not take into account guild force_locale. + """ + user_locale = self.luser.config.get('user_locale').value + interaction_locale = interaction.locale.value if interaction else None + guild_locale = self.lguild.config.get('guild_locale').value + + locale = user_locale or interaction_locale + locale = locale or guild_locale + locale = locale or SOURCE_LOCALE + return locale + async def touch_discord_model(self, member: discord.Member): """ Update saved Discord model attributes for this member. @@ -82,3 +98,15 @@ class LionMember(Timezoned): except discord.HTTPException: pass return member + + async def remove_role(self, role: discord.Role): + member = await self.fetch_member() + if member is not None and role in member.roles: + try: + await member.remove_roles(role) + except discord.HTTPException: + # TODO: Logging, audit logging + pass + else: + # TODO: Persistent role removal + ... diff --git a/src/core/setting_types.py b/src/core/setting_types.py index 5471b490..bba1d185 100644 --- a/src/core/setting_types.py +++ b/src/core/setting_types.py @@ -1,7 +1,14 @@ """ Additional abstract setting types useful for StudyLion settings. """ -from settings.setting_types import IntegerSetting +from typing import Optional +import json +import traceback + +import discord + +from settings.base import ParentID +from settings.setting_types import IntegerSetting, StringSetting from meta import conf from meta.errors import UserInputError from constants import MAX_COINS @@ -62,3 +69,202 @@ class CoinSetting(IntegerSetting): "{coin}**{amount}**" )).format(coin=conf.emojis.coin, amount=data) return formatted + + +class MessageSetting(StringSetting): + """ + Typed Setting ABC representing a message sent to Discord. + + Data is a json-formatted string dict with at least one of the fields 'content', 'embed', 'embeds' + Value is the corresponding dictionary + """ + # TODO: Extend to support format keys + + _accepts = _p( + 'settype:message|accepts', + "JSON formatted raw message data" + ) + + @staticmethod + async def download_attachment(attached: discord.Attachment): + """ + Download a discord.Attachment with some basic filetype and file size validation. + """ + t = ctx_translator.get().t + + error = None + decoded = None + if attached.content_type and not ('json' in attached.content_type): + error = t(_p( + 'settype:message|download|error:not_json', + "The attached message data is not a JSON file!" + )) + elif attached.size > 10000: + error = t(_p( + 'settype:message|download|error:size', + "The attached message data is too large!" + )) + else: + content = await attached.read() + try: + decoded = content.decode('UTF-8') + except UnicodeDecodeError: + error = t(_p( + 'settype:message|download|error:decoding', + "Could not decode the message data. Please ensure it is saved with the `UTF-8` encoding." + )) + + if error is not None: + raise UserInputError(error) + else: + return decoded + + @classmethod + def value_to_args(cls, parent_id: ParentID, value: dict, **kwargs): + if not value: + return None + + args = {} + args['content'] = value.get('content', "") + if 'embed' in value: + embed = discord.Embed.from_dict(value['embed']) + args['embed'] = embed + if 'embeds' in value: + embeds = [] + for embed_data in value['embeds']: + embeds.append(discord.Embed.from_dict(embed_data)) + + @classmethod + def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs): + if value and any(value.get(key, None) for key in ('content', 'embed', 'embeds')): + data = json.dumps(value) + else: + data = None + return data + + @classmethod + def _data_to_value(cls, parent_id: ParentID, data: Optional[str], **kwargs): + if data: + value = json.loads(data) + else: + value = None + return value + + @classmethod + async def _parse_string(cls, parent_id: ParentID, string: str, **kwargs): + """ + Provided user string can be downright random. + + If it isn't json-formatted, treat it as the content of the message. + If it is, do basic checking on the length and embeds. + """ + string = string.strip() + if not string or string.lower() == 'none': + return None + + t = ctx_translator.get().t + + error_tip = t(_p( + 'settype:message|error_suffix', + "You can view, test, and fix your embed using the online [embed builder]({link})." + )).format( + link="https://glitchii.github.io/embedbuilder/?editor=json" + ) + + if string.startswith('{') and string.endswith('}'): + # Assume the string is a json-formatted message dict + try: + value = json.loads(string) + except json.JSONDecodeError as err: + error = t(_p( + 'settype:message|error:invalid_json', + "The provided message data was not a valid JSON document!\n" + "`{error}`" + )).format(error=str(err)) + raise UserInputError(error + '\n' + error_tip) + + if not isinstance(value, dict) or not any(value.get(key, None) for key in ('content', 'embed', 'embeds')): + error = t(_p( + 'settype:message|error:json_missing_keys', + "Message data must be a JSON object with at least one of the following fields: " + "`content`, `embed`, `embeds`" + )) + raise UserInputError(error + '\n' + error_tip) + + embed_data = value.get('embed', None) + if not isinstance(embed_data, dict): + error = t(_p( + 'settype:message|error:json_embed_type', + "`embed` field must be a valid JSON object." + )) + raise UserInputError(error + '\n' + error_tip) + + embeds_data = value.get('embeds', []) + if not isinstance(embeds_data, list): + error = t(_p( + 'settype:message|error:json_embeds_type', + "`embeds` field must be a list." + )) + raise UserInputError(error + '\n' + error_tip) + + if embed_data and embeds_data: + error = t(_p( + 'settype:message|error:json_embed_embeds', + "Message data cannot include both `embed` and `embeds`." + )) + raise UserInputError(error + '\n' + error_tip) + + content_data = value.get('content', "") + if not isinstance(content_data, str): + error = t(_p( + 'settype:message|error:json_content_type', + "`content` field must be a string." + )) + raise UserInputError(error + '\n' + error_tip) + + # Validate embeds, which is the most likely place for something to go wrong + embeds = [embed_data] if embed_data else embeds_data + try: + for embed in embeds: + discord.Embed.from_dict(embed) + except Exception as e: + # from_dict may raise a range of possible exceptions. + raw_error = ''.join( + traceback.TracebackException.from_exception(e).format_exception_only() + ) + error = t(_p( + 'ui:settype:message|error:embed_conversion', + "Could not parse the message embed data.\n" + "**Error:** `{exception}`" + )).format(exception=raw_error) + raise UserInputError(error + '\n' + error_tip) + + # At this point, the message will at least successfully convert into MessageArgs + # There are numerous ways it could still be invalid, e.g. invalid urls, or too-long fields + # or the total message content being too long, or too many fields, etc + # This will need to be caught in anything which displays a message parsed from user data. + else: + # Either the string is not json formatted, or the formatting is broken + # Assume the string is a content message + value = { + 'content': string + } + return json.dumps(value) + + @classmethod + def _format_data(cls, parent_id: ParentID, data: Optional[str], **kwargs): + if not data: + return None + + value = cls._data_to_value(parent_id, data, **kwargs) + content = value.get('content', "") + if 'embed' in value or 'embeds' in value or len(content) > 1024: + t = ctx_translator.get().t + formatted = t(_p( + 'settype:message|format:too_long', + "Too long to display! See Preview." + )) + else: + formatted = content + + return formatted diff --git a/src/modules/reminders/cog.py b/src/modules/reminders/cog.py index 77c5accc..3683ff37 100644 --- a/src/modules/reminders/cog.py +++ b/src/modules/reminders/cog.py @@ -323,11 +323,10 @@ class Reminders(LionCog): logger.debug( f"Executed reminder ." ) - except discord.HTTPException: + except discord.HTTPException as e: await reminder.update(failed=True) logger.debug( - f"Reminder could not be sent.", - exc_info=True + f"Reminder could not be sent: {e.text}", ) except Exception: await reminder.update(failed=True) diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 3aeef480..7d6d0ff4 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -6,24 +6,31 @@ 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.ui.button import ButtonStyle +from discord.app_commands import Range, Transform 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.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation from meta.sharding import THIS_SHARD from utils.lib import utc_now, error_embed -from utils.ui import Confirm, ChoicedEnum, Transformed +from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents +from utils.transformers import DurationTransformer +from utils.monitor import TaskMonitor from constants import MAX_COINS +from data import NULL -from wards import low_management_ward +from wards import low_management_ward, equippable_role from . import babel, logger from .data import RoleMenuData, MenuType from .rolemenu import RoleMenu, RoleMenuRole from .ui.menueditor import MenuEditor +from .ui.menus import MenuList from .templates import templates +from .menuoptions import RoleMenuOptions as RMOptions +from .roleoptions import RoleMenuRoleOptions as RMROptions _p = babel._p @@ -56,36 +63,204 @@ class MenuStyleParam(ChoicedEnum): return self.value[1] +# ----- Context Menu ----- +@appcmds.context_menu( + name=_p('ctxcmd:rolemenu', "Role Menu Editor") +) +@appcmds.guild_only +async def rolemenu_ctxcmd(interaction: discord.Interaction, message: discord.Message): + # TODO: Permission wards! + bot = interaction.client + self = bot.get_cog('RoleMenuCog') + + await interaction.response.defer(thinking=True, ephemeral=True) + # Lookup the rolemenu in the active message cache + menuid = self.live_menus[interaction.guild.id].get(message.id, None) + if menuid is None: + # Create a new menu + target_mine = (message.author == message.guild.me) + + # Default menu type is Button if we own the message, reaction otherwise + if target_mine: + menu_type = MenuType.BUTTON + else: + menu_type = MenuType.REACTION + + # TODO: Something to avoid duliplication + # Also localise + name = 'Untitled' + + message_data = {} + message_data['content'] = message.content + if message.embeds: + message_data['embed'] = message.embeds[0].to_dict() + rawmessage = json.dumps(message_data) + + # Create RoleMenu, set options if given + menu = await RoleMenu.create( + bot, + guildid=message.guild.id, + channelid=message.channel.id, + messageid=message.id, + name=name, + enabled=True, + menutype=menu_type, + rawmessage=rawmessage, + ) + else: + menu = await RoleMenu.fetch(self.bot, menuid) + menu._message = message + + # Open the editor + editor = MenuEditor(self.bot, menu, callerid=interaction.user.id) + await editor.run(interaction) + await editor.wait() + + +class ExpiryMonitor(TaskMonitor): + ... + + 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 + self.live_menus = RoleMenu.attached_menus # guildid -> messageid -> menuid + + # Expiry manage + self.expiry_monitor = ExpiryMonitor(executor=self._expire) # ----- Initialisation ----- async def cog_load(self): await self.data.init() + self.bot.tree.add_command(rolemenu_ctxcmd) + if self.bot.is_ready(): await self.initialise() async def cog_unload(self): - ... + for menu in list(RoleMenu._menus.values()): + menu.detach() + self.live_menus.clear() + if self.expiry_monitor._monitor_task: + self.expiry_monitor._monitor_task.cancel() + self.bot.tree.remove_command(rolemenu_ctxcmd) @LionCog.listener('on_ready') @log_wrap(action="Initialise Role Menus") async def initialise(self): - ... + self.expiry_monitor = ExpiryMonitor(executor=self._expire) + self.expiry_monitor.start() + + guildids = [guild.id for guild in self.bot.guilds] + if guildids: + await self._initialise_guilds(*guildids) + + async def _initialise_guilds(self, *guildids): + """ + Initialise the RoleMenus in the given guilds, + and launch their expiry tasks if required. + """ + # Fetch menu data from the guilds + menu_rows = await self.data.RoleMenu.fetch_where(guildid=guildids) + if not menu_rows: + # Nothing to initialise + return + + menuids = [row.menuid for row in menu_rows] + guildids = {row.guildid for row in menu_rows} + + # Fetch menu roles from these menus + role_rows = await self.data.RoleMenuRole.fetch_where(menuid=menuids).order_by('menuroleid') + + # Initialise MenuRoles and partition by menu + role_menu_roles = defaultdict(dict) + for row in role_rows: + mrole = RoleMenuRole(self.bot, row) + role_menu_roles[row.menuid][row.menuroleid] = mrole + + # Bulk fetch the Lion Guilds + await self.bot.core.lions.fetch_guilds(*guildids) + + # Initialise and attach RoleMenus + for menurow in menu_rows: + menu = RoleMenu(self.bot, menurow, role_menu_roles[menurow.menuid]) + await menu.attach() + + # Fetch all unexpired expiring menu roles from these menus + expiring = await self.data.RoleMenuHistory.fetch_expiring_where(menuid=menuids) + if expiring: + await self.schedule_expiring(*expiring) # ----- Cog API ----- - async def register_menus(*menus): - ... + async def fetch_guild_menus(self, guildid): + """ + Retrieve guild menus for the given guildid. + Uses cache where possible. + """ + # TODO: For efficiency, cache the guild menus as well + # Current guild-key cache only caches the *active* guild menus, which is insufficent + # But we actually keep all guild menus in the RoleMenu cache anyway, + # so we just need to refine that cache a bit. + # For now, we can live with every acmpl hitting the database. + rows = await self.data.RoleMenu.fetch_where(guildid=guildid) + menuids = [row.menuid for row in rows] - async def deregister_menus(*menus): - ... + menus = [] + for menuid in menuids: + menus.append(await RoleMenu.fetch(self.bot, menuid)) + + return menus + + async def schedule_expiring(self, *rows: RoleMenuData.RoleMenuHistory): + """ + Schedule expiry of given equip rows. + """ + tasks = [ + (row.equipid, row.expires_at.timestamp()) for row in rows if row.expires_at + ] + if tasks: + self.expiry_monitor.schedule_tasks(*tasks) + logger.debug( + f"Scheduled rolemenu expiry tasks: {tasks}" + ) + + async def cancel_expiring_tasks(self, *equipids): + """ + Cancel (task) expiry of given equipds, if they are scheduled. + """ + self.expiry_monitor.cancel_tasks(*equipids) + logger.debug( + f"Cancelled rolemenu expiry tasks: {equipids}" + ) + + async def _expire(self, equipid: int): + """ + Attempt to expire the given equipid. + + The equipid may no longer be valid, or may be unexpirable. + If the bot is no longer in the server, ignores the expiry. + If the member is no longer in the server, removes the role from persisted roles, if applicable. + """ + logger.debug(f"Expiring RoleMenu equipped role {equipid}") + rows = await self.data.RoleMenuHistory.fetch_expiring_where(equipid=equipid) + if rows: + equip_row = rows[0] + menu = await self.data.RoleMenu.fetch(equip_row.menuid) + guild = self.bot.get_guild(menu.guildid) + if guild is not None: + role = guild.get_role(equip_row.roleid) + if role is not None: + lion = await self.bot.core.lions.fetch_member(guild.id, equip_row.userid) + await lion.remove_role(role) + now = utc_now() + await equip_row.update(removed_at=now) + else: + # equipid is no longer valid or is not expiring + pass # ----- Private Utils ----- async def _parse_msg(self, guild: discord.Guild, msgstr: str) -> discord.Message: @@ -151,16 +326,92 @@ class RoleMenuCog(LionCog): ... # ----- Event Handlers ----- - # Message delete handler - # Role delete handler - # Reaction handler - # Guild leave handler (stop listening) - # Guild join handler (start listening) + @LionCog.listener('on_raw_reaction_add') + @LionCog.listener('on_raw_reaction_remove') + async def on_reaction(self, payload: discord.RawReactionActionEvent): + """ + Check the message is an active message. - # ----- Context Menu ----- + If so, fetch the associated menu and pass on the reaction event. + """ + if payload.member and payload.member.bot: + return + + menuid = self.live_menus[payload.guild_id].get(payload.message_id, None) + if menuid is not None: + menu = await RoleMenu.fetch(self.bot, menuid) + if menu.data.menutype is MenuType.REACTION: + await menu.handle_reaction(payload) + + # Message delete handler + @LionCog.listener('on_message_delete') + async def detach_menu(self, message: discord.Message): + """ + Detach any associated menu. + + Set _message and messageid to None. + """ + menuid = self.live_menus[message.guild.id].get(message.id, None) + if menuid is not None: + menu = await RoleMenu.fetch(self.bot, menuid) + menu.detach() + menu._message = None + await menu.data.update(messageid=None) + logger.info( + f"RoleMenu attached message deleted." + ) + + # Role delete handler + @LionCog.listener('on_role_delete') + async def delete_menu_role(self, role: discord.Role): + """ + Delete any rolemenuroles associated with the role. + + Set equip removed_at. + Cancel any associated expiry tasks. + """ + records = await self.data.RoleMenuRole.table.delete_where(roleid=role.id) + if records: + menuids = set(record['menuid'] for record in records) + for menuid in menuids: + menu = await RoleMenu.fetch(self.bot, menuid) + await menu.reload_roles() + await menu.update_message() + equip_records = await self.data.RoleMenuHistory.table.update_where( + (self.data.RoleMenuHistory.removed_at == NULL), + roleid=role.id + ).set(removed_at=utc_now()) + if equip_records: + equipids = [equip_records['equipid'] for record in equip_records] + await self.cancel_expiring_tasks(*equipids) + + # Guild leave handler (stop listening) + @LionCog.listener('on_guild_leave') + async def unload_guild_menus(self, guild: discord.Guild): + """ + Detach any listening menus from this guild. + Cancel any expiry tasks. + """ + menu_data = await self.data.RoleMenu.fetch_where(guildid=guild.id) + if menu_data: + listening = list(self.live_menus[guild.id].values()) + for menu in listening: + menu.detach() + menuids = [row.menuid for row in menu_data] + expiring = await self.data.RoleMenuHistory.fetch_expiring_where(menuid=menuids) + if expiring: + equipids = [row.equipid for row in expiring] + await self.cancel_expiring_tasks(*equipids) + + # Guild join handler (start listening) + @LionCog.listener('on_guild_join') + async def load_guild_menus(self, guild: discord.Guild): + """ + Run initialise for this guild. + """ + await self._initialise_guilds(guild.id) # ----- Commands ----- - @cmds.hybrid_command( name=_p('cmd:rolemenus', "rolemenus"), description=_p( @@ -168,10 +419,145 @@ class RoleMenuCog(LionCog): "View and configure the role menus in this server." ) ) + @appcmds.guild_only + @appcmds.default_permissions(manage_roles=True) async def rolemenus_cmd(self, ctx: LionContext): - # Spawn the menus UI - # Maybe accept a channel here to restrict the menus - ... + if not ctx.guild: + return + if not ctx.interaction: + return + t = self.bot.translator.t + + # Ward for manage_roles + if not ctx.author.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenus|error:author_perms', + "You need the `MANAGE_ROLES` permission in order to manage the server role menus." + )) + ) + if not ctx.guild.me.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenus|error:my_perms', + "I lack the `MANAGE_ROLES` permission required to offer roles from role menus." + )) + ) + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + menusui = MenuList(self.bot, ctx.guild, callerid=ctx.author.id) + await menusui.run(ctx.interaction) + await menusui.wait() + + async def _menu_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + """ + Generate a list of Choices matching the given menu string. + + Menus are matched by name. + """ + # TODO: Make this more efficient so we aren't hitting data for every acmpl + t = self.bot.translator.t + guildid = interaction.guild.id + + guild_menus = await self.fetch_guild_menus(guildid) + + choices = [] + to_match = partial.strip().lower() + for menu in guild_menus: + if to_match in menu.data.name.lower(): + choice_name = menu.data.name + choice_value = f"menuid:{menu.data.menuid}" + choices.append( + appcmds.Choice(name=choice_name, value=choice_value) + ) + + if not choices: + # Offer 'no menus matching' choice instead, with current partial + choice_name = t(_p( + 'acmpl:menus|choice:no_choices|name', + "No role menus matching '{partial}'" + )).format(partial=partial) + choice_value = partial + choice = appcmds.Choice( + name=choice_name, value=choice_value + ) + choices.append(choice) + + return choices[:25] + + async def _role_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + """ + Generate a list of Choices representing menu roles matching the given partial. + + Roles are matched by label and role name. Role mentions are acceptable. + Matches will only be given if the menu parameter has already been entered. + """ + t = self.bot.translator.t + menu_key = t(_p( + 'acmpl:menuroles|param:menu|keyname', "menu" + ), locale=interaction.data.get('locale', 'en-US')) + menu_name = interaction.namespace[menu_key] if menu_key in interaction.namespace else None + if menu_name is None: + choice_name = t(_p( + 'acmpl:menuroles|choice:no_menu|name', + "Please select a menu first" + )) + choice_value = partial + choices = [appcmds.Choice(name=choice_name, value=choice_value)] + else: + # Resolve the menu name + menu: RoleMenu + if menu_name.startswith('menuid:') and menu_name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(menu_name[7:]) + menu = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + guild_menus = await self.fetch_guild_menus(interaction.guild.id) + to_match = menu_name.strip().lower() + menu = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if menu is None: + choice = appcmds.Choice( + name=t(_p( + 'acmpl:menuroles|choice:invalid_menu|name', + "Menu '{name}' does not exist!" + )).format(name=menu_name), + value=partial + ) + choices = [choice] + else: + # We have a menu and can match roles + to_match = partial.strip().lower() + choices = [] + for mrole in menu.roles: + matching = (to_match in mrole.config.label.value.lower()) + role = interaction.guild.get_role(mrole.data.roleid) + if not matching and role: + matching = matching or (to_match in role.name.lower()) + matching = matching or (to_match in role.mention) + if matching: + if role and (mrole.data.label != role.name): + name = f"{mrole.data.label} (@{role.name})" + else: + name = mrole.data.label + choice = appcmds.Choice( + name=name, + value=f"<@&{mrole.data.roleid}>" + ) + choices.append(choice) + if not choices: + choice = appcmds.Choice( + name=t(_p( + 'acmpl:menuroles|choice:no_matching|name', + "No roles in this menu matching '{partial}'" + )).format(partial=partial), + value=partial + ) + return choices[:25] @cmds.hybrid_group( name=_p('group:rolemenu', "rolemenu"), @@ -181,6 +567,7 @@ class RoleMenuCog(LionCog): ) ) @appcmds.guild_only() + @appcmds.default_permissions(manage_roles=True) async def rolemenu_group(self, ctx: LionBot): ... @@ -194,17 +581,53 @@ class RoleMenuCog(LionCog): @appcmds.choices( template=[ template.as_choice() for template in templates.values() - ] + ], ) + @appcmds.rename( + name=RMOptions.Name._display_name, + sticky=RMOptions.Sticky._display_name, + refunds=RMOptions.Refunds._display_name, + obtainable=RMOptions.Obtainable._display_name, + required_role=RMOptions.RequiredRole._display_name, + message=_p('cmd:rolemenu_create|param:message', "message_link"), + menu_style=_p('cmd:rolemenu_create|param:menu_style', "menu_style"), + template=_p('cmd:rolemenu_create|param:remplate', "template"), + rawmessage=_p('cmd:rolemenu_create|param:rawmessage', "custom_message"), + ) + @appcmds.describe( + name=RMOptions.Name._desc, + sticky=RMOptions.Sticky._desc, + refunds=RMOptions.Refunds._desc, + obtainable=RMOptions.Obtainable._desc, + required_role=RMOptions.RequiredRole._desc, + message=_p( + 'cmd:rolemenu_create|param:message|desc', + "Link to an existing message to turn it into a (reaction) role menu" + ), + menu_style=_p( + 'cmd:rolemenu_create|param:menu_style', + "Selection style for this menu (using buttons, dropdowns, or reactions)" + ), + template=_p( + 'cmd:rolemenu_create|param:template', + "Template to use for the menu message body" + ), + rawmessage=_p( + 'cmd:rolemenu_create|param:rawmessage', + "Attach a custom menu message to use" + ), + ) + @appcmds.default_permissions(manage_roles=True) 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, + required_role: Optional[discord.Role] = None, template: Optional[appcmds.Choice[int]] = None, + rawmessage: Optional[discord.Attachment] = None, ): # Type checking guards if ctx.guild is None: @@ -215,6 +638,22 @@ class RoleMenuCog(LionCog): t = self.bot.translator.t await ctx.interaction.response.defer(thinking=True) + # Ward for manage_roles + if not ctx.author.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:author_perms', + "You need the `MANAGE_ROLES` permission in order to create new role menus." + )) + ) + if not ctx.guild.me.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:my_perms', + "I lack the `MANAGE_ROLES` permission needed to offer roles from role menus." + )) + ) + # Parse provided target message if given if message is None: target_message = None @@ -225,14 +664,15 @@ class RoleMenuCog(LionCog): 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()): + matching = await self.data.RoleMenu.fetch_where(messageid=target_message.id) + if matching: 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'] + edit_cmd=self.bot.core.mention_cache['rolemenu editmenu'] ) ) @@ -249,19 +689,38 @@ class RoleMenuCog(LionCog): 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)." + 'cmd:rolemenu_create|error:style_notmine', + "I cannot create a `{style}` style menu on a message I didn't send! (Discord restriction)." )).format(style=t(menu_style.value[0])) ) + if rawmessage is not None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:rawmessage_notmine', + "Cannot apply a custom menu message to {message} because I do not own this message!" + )).format( + message=target_message.jump_url + ) + ) + if template is not None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_create|error:template_notmine', + "Cannot apply a menu message template to {message} because I do not own this message!" + )).format( + message=target_message.jump_url + ) + ) # 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()): + matching = await self.data.RoleMenu.fetch_where(name=name) + if matching: 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']) + )).format(name=name, edit_cmd=self.bot.core.mention_cache['rolemenu editmenu']) ) templateid = template.value if template is not None else None @@ -272,12 +731,18 @@ class RoleMenuCog(LionCog): message_data['embed'] = target_message.embeds[0].to_dict() rawmessage = json.dumps(message_data) else: - rawmessage = None - if templateid is None: - templateid = 0 + if rawmessage is not None: + # Attempt to parse rawmessage + rawmessagecontent = await RMOptions.Message.download_attachment(rawmessage) + rawmessagedata = await RMOptions.Message._parse_string(0, rawmessagecontent) + else: + rawmessagedata = None + if templateid is None: + templateid = 0 # Create RoleMenu data, set options if given - data = await self.data.RoleMenu.create( + menu = await RoleMenu.create( + self.bot, guildid=ctx.guild.id, channelid=target_message.channel.id if target_message else None, messageid=target_message.id if target_message else None, @@ -289,10 +754,12 @@ class RoleMenuCog(LionCog): obtainable=obtainable, menutype=menu_type, templateid=templateid, - rawmessage=rawmessage, + rawmessage=rawmessagedata, ) - # Create RoleMenu - menu = RoleMenu(self.bot, data, []) + + # If the message already exists and we own it, we may need to update it + if target_message and target_mine: + await menu.update_message() # Open editor, with preview if not a reaction role message editor = MenuEditor(self.bot, menu, callerid=ctx.author.id) @@ -303,15 +770,257 @@ class RoleMenuCog(LionCog): name=_p('cmd:rolemenu_edit', "editmenu"), description=_p( 'cmd:rolemenu_edit|desc', - "Edit an existing (or in-creation) role menu." + "Edit an existing role menu." ) ) - async def rolemenu_edit_cmd(self, ctx: LionContext): - # Parse target + @appcmds.choices( + template=[ + template.as_choice() for template in templates.values() + ], + ) + @appcmds.rename( + name=_p('cmd:rolemenu_edit|param:name', "name"), + new_name=_p('cmd:rolemenu_edit|param:new_name', "new_name"), + channel=_p('cmd:rolemenu_edit|param:channel', "new_channel"), + sticky=RMOptions.Sticky._display_name, + refunds=RMOptions.Refunds._display_name, + obtainable=RMOptions.Obtainable._display_name, + required_role=RMOptions.RequiredRole._display_name, + menu_style=_p('cmd:rolemenu_edit|param:menu_style', "menu_style"), + template=_p('cmd:rolemenu_edit|param:remplate', "template"), + rawmessage=_p('cmd:rolemenu_edit|param:rawmessage', "custom_message"), + ) + @appcmds.describe( + name=_p( + 'cmd:rolemenu_edit|param:name|desc', + "Name of the menu to edit" + ), + channel=_p( + 'cmd:rolemenu_edit|param:channel|desc', + "Server channel to move the menu to" + ), + new_name=RMOptions.Name._desc, + sticky=RMOptions.Sticky._desc, + refunds=RMOptions.Refunds._desc, + obtainable=RMOptions.Obtainable._desc, + required_role=RMOptions.RequiredRole._desc, + menu_style=_p( + 'cmd:rolemenu_edit|param:menu_style', + "Selection style for this menu (using buttons, dropdowns, or reactions)" + ), + template=_p( + 'cmd:rolemenu_edit|param:template', + "Template to use for the menu message body" + ), + rawmessage=_p( + 'cmd:rolemenu_edit|param:rawmessage', + "Attach a custom menu message to use" + ), + ) + async def rolemenu_edit_cmd(self, ctx: LionContext, + name: appcmds.Range[str, 1, 64], + new_name: Optional[appcmds.Range[str, 1, 64]] = None, + channel: Optional[discord.TextChannel | discord.VoiceChannel] = None, + menu_style: Optional[Transformed[MenuStyleParam, cmdopt.string]] = None, + sticky: Optional[bool] = None, + refunds: Optional[bool] = None, + obtainable: Optional[appcmds.Range[int, 1, 25]] = None, + required_role: Optional[discord.Role] = None, + template: Optional[appcmds.Choice[int]] = None, + rawmessage: Optional[discord.Attachment] = 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(ephemeral=True, thinking=True) + + # Wards for manage_roles + if not ctx.author.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|error:author_perms', + "You need the `MANAGE_ROLES` permission in order to edit role menus." + )) + ) + if not ctx.guild.me.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|error:my_perms', + "I lack the `MANAGE_ROLES` permission needed to offer roles from role menus." + )) + ) + + # Parse target menu from name + guild_menus = await self.fetch_guild_menus(ctx.guild.id) + target: RoleMenu + if name.startswith('menuid:') and name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(name[7:]) + target = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + to_match = name.strip().lower() + target = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if target is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|error:menu_not_found', + "This server does not have a role menu called `{name}`!" + )).format(name=name) + ) + await target.fetch_message() + # Parse provided options - # Set options if provided - # Open editor with preview - ... + reposting = channel is not None + managed = target.managed + + update_args = {} + ack_lines = [] + error_lines = [] + + if new_name is not None: + # Check whether the name already exists + for menu in guild_menus: + if menu.data.name.lower() == new_name.lower() and menu.data.menuid != target.data.menuid: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|parse:new_name|error:name_exists', + "A role menu with the name **{new_name}** already exists!" + )).format(new_name=new_name) + ) + name_config = target.config.name + name_config.value = new_name + update_args[name_config._column] = name_config.data + ack_lines.append(name_config.update_message) + + if sticky is not None: + sticky_config = target.config.sticky + sticky_config.value = sticky + update_args[sticky_config._column] = sticky_config.data + ack_lines.append(sticky_config.update_message) + + if refunds is not None: + refunds_config = target.config.refunds + refunds_config.value = refunds + update_args[refunds_config._column] = refunds_config.data + ack_lines.append(refunds_config.update_message) + + if obtainable is not None: + obtainable_config = target.config.obtainable + obtainable_config.value = obtainable + update_args[obtainable_config._column] = obtainable_config.data + ack_lines.append(obtainable_config.update_message) + + if required_role is not None: + required_role_config = target.config.required_role + required_role_config.value = required_role + update_args[required_role_config._column] = required_role_config.data + ack_lines.append(required_role_config.update_message) + + if template is not None: + if not managed and not reposting: + raise UserInputError( + t(_p( + 'cmd:rolemenu_edit|parse:template|error:not_managed', + "Cannot set a template message for a role menu attached to a message I did not send." + )) + ) + templateid = template.value + if templateid == -1: + templateid = None + update_args[self.data.RoleMenu.templateid.name] = templateid + if templateid is not None: + ack_lines.append( + t(_p( + 'cmd:rolemenu_edit|parse:template|success:template', + "Now using the `{name}` menu message template." + )).format(name=t(templates[templateid].name)) + ) + else: + ack_lines.append( + t(_p( + 'cmd:rolemenu_edit|parse:template|success:custom', + "Now using a custom menu message." + )) + ) + # TODO: Generate the custom message from the template if it doesn't exist + + if rawmessage is not None: + msg_config = target.config.rawmessage + content = await msg_config.download_attachment(rawmessage) + data = await msg_config._parse_string(content) + update_args[msg_config._column] = data + if template is None: + update_args[self.data.RoleMenu.templateid.name] = None + ack_lines.append(msg_config.update_message) + + # Update the data, if applicable + if update_args: + await target.data.update(**update_args) + + # If we are reposting, do the repost + if reposting: + try: + await target.repost_to(channel) + ack_lines.append( + t(_p( + 'cmd:rolemenu_edit|repost|success', + "The role menu is now available at {message}" + )).format(message=target.jump_link) + ) + if target.data.menutype is MenuType.REACTION: + try: + await target.update_reactons() + except SafeCancellation as e: + error_lines.append(e.msg) + except discord.Forbidden: + error_lines.append(t(_p( + 'cmd:rolemenu_edit|repost|error:forbidden', + "Cannot update channel! I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission in {channel}." + )).format(channel=channel.mention)) + except discord.HTTPException as e: + error_lines.append(t(_p( + 'cmd:rolemenu_edit|repost|error:unknown', + "An unknown error occurred trying to repost the menu to {channel}.\n" + "**Error:** `{exception}`" + )).format(channel=channel.mention, exception=e.text)) + else: + await target.update_message() + + # Ack the updates + if ack_lines or error_lines: + tick = self.bot.config.emojis.tick + cross = self.bot.config.emojis.cancel + await ctx.interaction.edit_original_response( + embed=discord.Embed( + colour=discord.Colour.brand_green() if ack_lines else discord.Colour.brand_red(), + description='\n'.join(( + *(f"{tick} {line}" for line in ack_lines), + *(f"{cross} {line}" for line in error_lines), + )) + ) + ) + + # Trigger listening MenuEditor update + listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid) + if (listen_key) not in MenuEditor._listening or not (ack_lines or error_lines): + ui = MenuEditor(self.bot, target, callerid=ctx.author.id) + await ui.run(ctx.interaction) + await ui.wait() + else: + ui = MenuEditor._listening[listen_key] + await ui.refresh() + await ui.update_preview() + + rolemenu_edit_cmd.autocomplete('name')(_menu_acmpl) @rolemenu_group.command( name=_p('cmd:rolemenu_delete', "delmenu"), @@ -320,44 +1029,563 @@ class RoleMenuCog(LionCog): "Delete a role menu." ) ) - async def rolemenu_delete_cmd(self, ctx: LionContext): + @appcmds.rename( + name=_p('cmd:rolemenu_delete|param:name', "menu") + ) + @appcmds.describe( + name=_p( + 'cmd:rolemenu_delete|param:name|desc', + "Name of the rolemenu to delete." + ) + ) + async def rolemenu_delete_cmd(self, ctx: LionContext, name: appcmds.Range[str, 1, 64]): + if ctx.guild is None: + return + if ctx.interaction is None: + return + + t = self.bot.translator.t + + if not ctx.author.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_delete|error:author_perms', + "You need the `MANAGE_ROLES` permission in order to manage the server role menus." + )) + ) + # Parse target - # Delete target - ... + guild_menus = await self.fetch_guild_menus(ctx.guild.id) + target: RoleMenu + if name.startswith('menuid:') and name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(name[7:]) + target = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + to_match = name.strip().lower() + target = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if target is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_delete|error:menu_not_found', + "This server does not have a role menu called `{name}`!" + )).format(name=name) + ) + await target.fetch_message() + + # Confirm + confirm_msg = t(_p( + 'cmd:rolemenu_delete|confirm|title', + "Are you sure you want to delete the role menu **{name}**? This is not reversible!" + )).format(name=target.data.name) + confirm = Confirm(confirm_msg, ctx.author.id) + confirm.confirm_button.label = t(_p( + 'cmd:rolemenu_delete|confirm|button:yes', + "Yes, Delete Now" + )) + confirm.confirm_button.style = ButtonStyle.red + confirm.cancel_button.label = t(_p( + 'cmd:rolemenu_delete|confirm|button:no', + "No, Cancel" + )) + confirm.cancel_button.style = ButtonStyle.green + try: + result = await confirm.ask(ctx.interaction, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + old_name = target.data.name + + # Delete them menu + await target.delete() + + # Close any menueditors that are listening + listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid) + listening = MenuEditor._listening.get(listen_key, None) + if listening is not None: + await listening.quit() + + # Ack deletion + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:rolemenu_delete|success|desc', + "Successfully deleted the menu **{name}**" + )).format(name=old_name) + ) + await ctx.interaction.followup.send(embed=embed, ephemeral=False) + + rolemenu_delete_cmd.autocomplete('name')(_menu_acmpl) @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." + "Add a new role to an existing role menu." ) ) + @appcmds.rename( + menu=_p( + 'cmd:rolemenu_addrole|param:menu', "menu" + ), + role=_p( + 'cmd:rolemenu_addrole|param:role', "role" + ), + label=RMROptions.Label._display_name, + emoji=RMROptions.Emoji._display_name, + description=RMROptions.Description._display_name, + price=RMROptions.Price._display_name, + duration=RMROptions.Duration._display_name, + ) + @appcmds.describe( + menu=_p( + 'cmd:rolemenu_addrole|param:menu|desc', + "Name of the menu to add a role to" + ), + role=_p( + 'cmd:rolemenu_addrole|param:role|desc', + "Role to add to the menu" + ), + label=RMROptions.Label._desc, + emoji=RMROptions.Emoji._desc, + description=RMROptions.Description._desc, + price=RMROptions.Price._desc, + duration=_p( + 'cmd:rolemenu_addrole|param:duration|desc', + "Lifetime of the role after selection in minutes." + ), + ) async def rolemenu_addrole_cmd(self, ctx: LionContext, + menu: appcmds.Range[str, 1, 64], role: discord.Role, - message: Optional[str] = None, + label: Optional[appcmds.Range[str, 1, 100]] = None, + emoji: Optional[appcmds.Range[str, 0, 100]] = None, + description: Optional[appcmds.Range[str, 0, 100]] = None, + price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None, + duration: Optional[Transform[int, DurationTransformer(60)]] = None, ): - # Parse target menu, may need to create here + # Type checking guards + if not ctx.interaction: + return + if not ctx.guild: + return + + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + + # Permission ward + # Will check if the author has permission to manage this role + # Will check that the bot has permission to manage this role + # Raises UserInputError on lack of permissions + await equippable_role(self.bot, role, ctx.author) + + t = self.bot.translator.t + + # Parse target menu + name = menu + guild_menus = await self.fetch_guild_menus(ctx.guild.id) + target: RoleMenu + if name.startswith('menuid:') and name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(name[7:]) + target = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + to_match = name.strip().lower() + target = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if target is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_addrole|error:menu_not_found', + "This server does not have a role menu called `{name}`!" + )).format(name=name) + ) + await target.fetch_message() + target_is_reaction = (target.data.menutype is MenuType.REACTION) + # Parse target role - # Check author permissions - # Parse role options - # Create RoleMenuRole - # Ack, with open editor button - ... + existing = next( + (mrole for mrole in target.roles if mrole.data.roleid == role.id), + None + ) + parent_id = existing.data.menuroleid if existing is not None else role.id + + # Parse provided config + data_args = {} + ack_lines = [] + + if not existing: + # Creation args + data_args = { + 'menuid': target.data.menuid, + 'roleid': role.id, + } + + # label + # Use role name if not existing and not given + if (label is None) and (not existing): + label = role.name[:100] + if label is not None: + setting_cls = RMROptions.Label + data = setting_cls._data_from_value(parent_id, label) + data_args[setting_cls._column] = data + if existing: + instance = setting_cls(existing.data.menuroleid, data) + ack_lines.append(instance.update_message) + + # emoji + # Autogenerate emoji if not exists and not given + if (emoji is None) and (not existing): + emoji = next(target.unused_emojis(include_defaults=target_is_reaction), None) + if emoji is not None: + setting_cls = RMROptions.Emoji + data = await setting_cls._parse_string(parent_id, emoji, interaction=ctx.interaction) + data_args[setting_cls._column] = data + if existing: + instance = setting_cls(existing.data.menuroleid, data) + ack_lines.append(instance.update_message) + + # description + if description is not None: + setting_cls = RMROptions.Description + data = setting_cls._data_from_value(parent_id, description or None) + data_args[setting_cls._column] = data + if existing: + instance = setting_cls(existing.data.menuroleid, data) + ack_lines.append(instance.update_message) + + # price + if price is not None: + setting_cls = RMROptions.Price + data = setting_cls._data_from_value(parent_id, price or None) + data_args[setting_cls._column] = data + if existing: + instance = setting_cls(existing.data.menuroleid, data) + ack_lines.append(instance.update_message) + + # duration + if duration is not None: + setting_cls = RMROptions.Duration + data = setting_cls._data_from_value(parent_id, duration or None) + data_args[setting_cls._column] = data + if existing: + instance = setting_cls(existing.data.menuroleid, data) + ack_lines.append(instance.update_message) + + # Create or edit RoleMenuRole + if not existing: + # Do create + data = await self.data.RoleMenuRole.create(**data_args) + + # Ack creation + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:rolemenu_addrole|success:create|title', + "Added Menu Role" + )), + description=t(_p( + 'cmd:rolemenu_addrole|success:create|desc', + "Add the role {role} to the menu **{menu}**." + )).format( + role=role.mention, + menu=target.data.name + ) + ) + # Update target roles + await target.reload_roles() + elif data_args: + # Do edit + await existing.data.update(**data_args) + + # Ack edit + tick = self.bot.config.emojis.tick + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:rolemenu_addrole|success:edit|title', + "Menu Role updated" + )), + description='\n'.join( + f"{tick} {line}" for line in ack_lines + ) + ) + else: + # addrole was called on an existing role, but no options were modified + embed = discord.Embed( + colour=discord.Colour.orange(), + description=t(_p( + 'cmd:rolemenu_addrole|error:role_exists', + "The role {role} is already selectable from the menu **{menu}**" + )).format( + role=role.mention, menu=target.data.name + ) + ) + + listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid) + listening = MenuEditor._listening.get(listen_key, None) + if data_args: + # Update target and any listening editors + await target.update_message() + if target_is_reaction: + try: + await self.menu.update_reactons() + except SafeCancellation as e: + embed.add_field( + name=t(_p( + 'cmd:rolemenu_addrole|success|error:reaction|name', + "Note" + )), + value=e.msg + ) + if listening is not None: + await listening.refresh() + await listening.update_preview() + + # Ack, with open editor button if there is no open editor already + @AButton( + label=t(_p( + 'cmd:rolemenu_addrole|success|button:editor|label', + "Edit Menu" + )), + style=ButtonStyle.blurple + ) + async def editor_button(press: discord.Interaction, pressed): + ui = MenuEditor(self.bot, target, callerid=press.user.id) + await ui.run(press) + + await ctx.interaction.followup.send( + embed=embed, + ephemeral=True, + view=AsComponents(editor_button) if listening is None else discord.utils.MISSING + ) + + rolemenu_addrole_cmd.autocomplete('menu')(_menu_acmpl) @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)" + "Edit role options in an existing role menu." ) ) - async def rolemenu_editrole_cmd(self, ctx: LionContext): + @appcmds.rename( + menu=_p( + 'cmd:rolemenu_editrole|param:menu', "menu" + ), + menu_role=_p( + 'cmd:rolemenu_editrole|param:menu_role', "menu_role" + ), + role=_p( + 'cmd:rolemenu_editrole|param:role', "new_role" + ), + label=RMROptions.Label._display_name, + emoji=RMROptions.Emoji._display_name, + description=RMROptions.Description._display_name, + price=RMROptions.Price._display_name, + duration=RMROptions.Duration._display_name, + ) + @appcmds.describe( + menu=_p( + 'cmd:rolemenu_editrole|param:menu|desc', + "Name of the menu to edit the role for" + ), + menu_role=_p( + 'cmd:rolemenu_editrole|param:menu_role|desc', + "Label, name, or mention of the menu role to edit." + ), + role=_p( + 'cmd:rolemenu_editrole|param:role|desc', + "New server role this menu role should give." + ), + label=RMROptions.Label._desc, + emoji=RMROptions.Emoji._desc, + description=RMROptions.Description._desc, + price=RMROptions.Price._desc, + duration=_p( + 'cmd:rolemenu_editrole|param:duration|desc', + "Lifetime of the role after selection in minutes." + ), + ) + async def rolemenu_editrole_cmd(self, ctx: LionContext, + menu: appcmds.Range[str, 1, 64], + menu_role: appcmds.Range[str, 1, 64], + role: Optional[discord.Role] = None, + label: Optional[appcmds.Range[str, 1, 100]] = None, + emoji: Optional[appcmds.Range[str, 0, 100]] = None, + description: Optional[appcmds.Range[str, 0, 100]] = None, + price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None, + duration: Optional[Transform[int, DurationTransformer(60)]] = None, + ): + # Type checking wards + if not ctx.interaction: + return + if not ctx.guild: + return + await ctx.interaction.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + # Parse target menu + name = menu + guild_menus = await self.fetch_guild_menus(ctx.guild.id) + target_menu: RoleMenu + if name.startswith('menuid:') and name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(name[7:]) + target_menu = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + to_match = name.strip().lower() + target_menu = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if target_menu is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_editrole|error:menu_not_found', + "This server does not have a role menu called `{name}`!" + )).format(name=name) + ) + await target_menu.fetch_message() + # Parse target role - # Check author permissions + menu_roles = target_menu.roles + target_role: RoleMenuRole + if (maybe_id := menu_role.strip('<&@> ')).isdigit(): + # Assume given as role mention or id + # Note that acmpl choices also provide mention + roleid = int(maybe_id) + target_role = next( + (mrole for mrole in menu_roles if mrole.data.roleid == roleid), + None + ) + else: + # Assume given as mrole label + to_match = menu_role.strip().lower() + target_role = next( + (mrole for mrole in menu_roles if mrole.config.label.value.lower() == to_match), + None + ) + + if target_role is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_editrole|error:role_not_found', + "The menu **{menu}** does not have the role **{name}**" + )).format(menu=target_menu.data.name, name=menu_role) + ) + + # Check bot and author permissions + if current_role := ctx.guild.get_role(target_role.data.roleid): + await equippable_role(self.bot, current_role, ctx.author) + if role is not None: + await equippable_role(self.bot, role, ctx.author) + # Parse role options - # Either ack changes or open the RoleEditor - ... + data_args = {} + ack_lines = [] + + # new role + if role is not None: + config = target_role.config.role + config.value = role + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + # label + if label is not None: + config = target_role.config.label + config.value = label + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + # emoji + if emoji is not None: + config = target_role.config.emoji + config.data = await config._parse_string(config.parent_id, emoji, interaction=ctx.interaction) + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + # description + if description is not None: + config = target_role.config.description + config.data = await config._parse_string(config.parent_id, description) + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + # price + if price is not None: + config = target_role.config.price + config.value = price or None + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + # duration + if duration is not None: + config = target_role.config.duration + config.data = duration or None + data_args[config._column] = config.data + ack_lines.append(config.update_message) + + if data_args: + # Perform updates + await target_role.data.update(**data_args) + + # Ack updates + tick = self.bot.config.emojis.tick + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:rolemenu_editrole|success|title', + "Role menu role updated" + )), + description='\n'.join( + f"{tick} {line}" for line in ack_lines + ) + ) + + await target_menu.update_message() + if target_menu.data.menutype is MenuType.REACTION and emoji is not None: + try: + await target_menu.update_reactons() + except SafeCancellation as e: + embed.add_field( + name=t(_p( + 'cmd:rolemenu_editrole|success|error:reaction|name', + "Warning!" + )), + value=e.msg + ) + + await ctx.interaction.followup.send( + embed=embed, + ephemeral=True + ) + + listen_key = (ctx.channel.id, ctx.author.id, target_menu.data.menuid) + listening = MenuEditor._listening.get(listen_key, None) + + if (listening is None) or (not data_args): + ui = MenuEditor(self.bot, target_menu, callerid=ctx.author.id) + await ui.run(ctx.interaction) + await ui.wait() + else: + await listening.refresh() + await listening.update_preview() + + rolemenu_editrole_cmd.autocomplete('menu')(_menu_acmpl) + rolemenu_editrole_cmd.autocomplete('menu_role')(_role_acmpl) @rolemenu_group.command( name=_p('cmd:rolemenu_delrole', "delrole"), @@ -366,8 +1594,115 @@ class RoleMenuCog(LionCog): "Remove a role from a role menu." ) ) - async def rolemenu_delrole_cmd(self, ctx: LionContext): + @appcmds.rename( + menu=_p('cmd:rolemenu_delrole|param:menu', "menu"), + menu_role=_p('cmd:rolemenu_delrole|param:menu_role', "menu_role") + ) + @appcmds.describe( + menu=_p( + 'cmd:rolemenu_delrole|param:menu|desc', + "Name of the menu to delete the role from." + ), + menu_role=_p( + 'cmd:rolemenu_delrole|param:menu_role|desc', + "Name, label, or mention of the role to delete." + ) + ) + async def rolemenu_delrole_cmd(self, ctx: LionContext, + menu: appcmds.Range[str, 1, 64], + menu_role: appcmds.Range[str, 1, 64] + ): + # Typechecking guards + if ctx.guild is None: + return + if ctx.interaction is None: + return + t = self.bot.translator.t + + if not ctx.author.guild_permissions.manage_roles: + raise UserInputError( + t(_p( + 'cmd:rolemenu_delrole|error:author_perms', + "You need the `MANAGE_ROLES` permission in order to manage the server role menus." + )) + ) + # Parse target menu + name = menu + guild_menus = await self.fetch_guild_menus(ctx.guild.id) + target_menu: RoleMenu + if name.startswith('menuid:') and name[7:].isdigit(): + # Assume autogenerated from acmpl of the form menuid:id + menuid = int(name[7:]) + target_menu = await RoleMenu.fetch(self.bot, menuid) + else: + # Assume it should match a menu name (case-insensitive) + to_match = name.strip().lower() + target_menu = next( + (menu for menu in guild_menus if menu.data.name.lower() == to_match), + None + ) + + if target_menu is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_delrole|error:menu_not_found', + "This server does not have a role menu called `{name}`!" + )).format(name=name) + ) + await target_menu.fetch_message() + # Parse target role - # Remove role - ... + menu_roles = target_menu.roles + target_role: RoleMenuRole + if (maybe_id := menu_role.strip('<&@> ')).isdigit(): + # Assume given as role mention or id + # Note that acmpl choices also provide mention + roleid = int(maybe_id) + target_role = next( + (mrole for mrole in menu_roles if mrole.data.roleid == roleid), + None + ) + else: + # Assume given as mrole label + to_match = menu_role.strip().lower() + target_role = next( + (mrole for mrole in menu_roles if mrole.config.label.value.lower() == to_match), + None + ) + + if target_role is None: + raise UserInputError( + t(_p( + 'cmd:rolemenu_delrole|error:role_not_found', + "The menu **{menu}** does not have the role **{name}**" + )).format(menu=target_menu.data.name, name=menu_role) + ) + + await ctx.interaction.response.defer(thinking=True) + + # Remove role and update target menu + old_name = target_role.data.label + await target_role.data.delete() + await target_menu.reload_roles() + await target_menu.update_message() + + # Ack deletion + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:rolemenu_delrole|success', + "The role **{name}** was successfully removed from the menu **{menu}**." + )).format(name=old_name, menu=target_menu.config.name.value) + ) + await ctx.interaction.edit_original_response(embed=embed) + + # Update listening editor if it exists + listen_key = (ctx.channel.id, ctx.author.id, target_menu.data.menuid) + listening = MenuEditor._listening.get(listen_key, None) + if listening is not None: + await listening.refresh() + await listening.update_preview() + + rolemenu_delrole_cmd.autocomplete('menu')(_menu_acmpl) + rolemenu_delrole_cmd.autocomplete('menu_role')(_role_acmpl) diff --git a/src/modules/rolemenus/data.py b/src/modules/rolemenus/data.py index cca6b292..de52e89c 100644 --- a/src/modules/rolemenus/data.py +++ b/src/modules/rolemenus/data.py @@ -1,6 +1,6 @@ from enum import Enum -from data import Registry, RowModel, RegisterEnum, Column +from data import Registry, RowModel, RegisterEnum, Column, NULL from data.columns import Integer, Timestamp, String, Bool @@ -35,6 +35,9 @@ class RoleMenuData(Registry): templateid = Integer() rawmessage = String() + default_price = Integer() + event_log = Bool() + class RoleMenuRole(RowModel): _tablename_ = 'role_menu_roles' _cache_ = {} @@ -66,4 +69,17 @@ class RoleMenuData(Registry): obtained_at = Timestamp() transactionid = Integer() expires_at = Timestamp() - expired_at = Timestamp() + removed_at = Timestamp() + + @classmethod + def fetch_expiring_where(cls, *args, **kwargs): + """ + Fetch expiring equip rows. + + This returns an awaitable and chainable Select Query. + """ + return cls.fetch_where( + (cls.expires_at != NULL), + (cls.removed_at == NULL), + *args, **kwargs + ) diff --git a/src/modules/rolemenus/menuoptions.py b/src/modules/rolemenus/menuoptions.py index 1dbdac90..06d63b5e 100644 --- a/src/modules/rolemenus/menuoptions.py +++ b/src/modules/rolemenus/menuoptions.py @@ -10,16 +10,14 @@ from settings.setting_types import ( RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting ) +from core.setting_types import MessageSetting + 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() @@ -45,6 +43,10 @@ class RoleMenuConfig(ModelConfig): def obtainable(self): return self.get(RoleMenuOptions.Obtainable.setting_id) + @property + def rawmessage(self): + return self.get(RoleMenuOptions.Message.setting_id) + class RoleMenuOptions(SettingGroup): @RoleMenuConfig.register_model_setting @@ -56,6 +58,7 @@ class RoleMenuOptions(SettingGroup): 'menuset:name|desc', "Brief name for this role menu." ) + _accepts = _desc _long_desc = _p( 'menuset:name|long_desc', "The role menu name is displayed when selecting the menu in commands, " @@ -66,6 +69,16 @@ class RoleMenuOptions(SettingGroup): _model = RoleMenuData.RoleMenu _column = RoleMenuData.RoleMenu.name.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + resp = t(_p( + 'menuset:name|set_response', + "This role menu will now be called **{new_name}**." + )).format(new_name=value) + return resp + @RoleMenuConfig.register_model_setting class Sticky(ModelData, BoolSetting): setting_id = 'sticky' @@ -75,6 +88,7 @@ class RoleMenuOptions(SettingGroup): 'menuset:sticky|desc', "Whether the menu can be used to unequip roles." ) + _accepts = _desc _long_desc = _p( 'menuset:sticky|long_desc', "When enabled, members will not be able to remove equipped roles by selecting them in this menu. " @@ -86,6 +100,22 @@ class RoleMenuOptions(SettingGroup): _model = RoleMenuData.RoleMenu _column = RoleMenuData.RoleMenu.sticky.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'menuset:sticky|set_response:true', + "Members will no longer be able to remove roles with this menu." + )) + else: + resp = t(_p( + 'menuset:sticky|set_response:false', + "Members will now be able to remove roles with this menu." + )) + return resp + @RoleMenuConfig.register_model_setting class Refunds(ModelData, BoolSetting): setting_id = 'refunds' @@ -95,6 +125,7 @@ class RoleMenuOptions(SettingGroup): 'menuset:refunds|desc', "Whether removing a role will refund the purchase price for that role." ) + _accepts = _desc _long_desc = _p( 'menuset:refunds|long_desc', "When enabled, members who *purchased a role through this role menu* will obtain a full refund " @@ -107,6 +138,22 @@ class RoleMenuOptions(SettingGroup): _model = RoleMenuData.RoleMenu _column = RoleMenuData.RoleMenu.refunds.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'menuset:refunds|set_response:true', + "Members will now be refunded when removing a role with this menu." + )) + else: + resp = t(_p( + 'menuset:refunds|set_response:false', + "Members will no longer be refunded when removing a role with this menu." + )) + return resp + @RoleMenuConfig.register_model_setting class Obtainable(ModelData, IntegerSetting): setting_id = 'obtainable' @@ -116,16 +163,37 @@ class RoleMenuOptions(SettingGroup): 'menuset:obtainable|desc', "The maximum number of roles equippable from this menu." ) + _accepts = _desc _long_desc = _p( - 'menus:obtainable|long_desc', + 'menuset: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." + "This counts roles that were not obtained through the rolemenu system." + ) + _notset_str = _p( + 'menuset:obtainable|notset', + "Unlimited." ) _default = None _model = RoleMenuData.RoleMenu _column = RoleMenuData.RoleMenu.obtainable.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'menuset:obtainable|set_response:set', + "Members will be able to select a maximum of **{value}** roles from this menu." + )).format(value=value) + else: + resp = t(_p( + 'menuset:obtainable|set_response:unset', + "Members will be able to select any number of roles from this menu." + )) + return resp + @RoleMenuConfig.register_model_setting class RequiredRole(ModelData, RoleSetting): setting_id = 'required_role' @@ -135,6 +203,7 @@ class RoleMenuOptions(SettingGroup): 'menuset:required_role|desc', "Initial role required to use this menu." ) + _accepts = _desc _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." @@ -143,3 +212,56 @@ class RoleMenuOptions(SettingGroup): _model = RoleMenuData.RoleMenu _column = RoleMenuData.RoleMenu.required_roleid.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'menuset:required_role|set_response:set', + "Members will need to have the {role} role to use this menu." + )).format(role=self.formatted) + else: + resp = t(_p( + 'menuset:required_role|set_response:unset', + "Any member who can see the menu may use it." + )) + return resp + + @RoleMenuConfig.register_model_setting + class Message(ModelData, MessageSetting): + setting_id = 'message' + + _display_name = _p('menuset:message', "custom_message") + _desc = _p( + 'menuset:message|desc', + "Custom message data used to display the menu." + ) + _long_desc = _p( + 'menuset:message|long_desc', + "This setting determines the body of the menu message, " + "including the message content and the message embed(s). " + "While most easily modifiable through the `Edit Message` button, " + "raw JSON-formatted message data may also be uploaded via command." + ) + _default = None + + _model = RoleMenuData.RoleMenu + _column = RoleMenuData.RoleMenu.rawmessage.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'menuset:message|set_response:set', + "The role menu message has been set. Edit through the menu editor." + )).format(value=value.mention) + else: + resp = t(_p( + 'menuset:message|set_response:unset', + "The role menu message has been unset. Select a template through the menu editor." + )) + return resp diff --git a/src/modules/rolemenus/rolemenu.py b/src/modules/rolemenus/rolemenu.py index ed8cdd12..238e55b8 100644 --- a/src/modules/rolemenus/rolemenu.py +++ b/src/modules/rolemenus/rolemenu.py @@ -1,9 +1,44 @@ -from meta import LionBot +import json +from typing import Optional, TYPE_CHECKING +import datetime as dt +from collections import defaultdict +import discord +from discord.ui.select import Select, SelectOption +from discord.ui.button import Button, ButtonStyle + +from meta import LionBot +from meta.errors import UserInputError, SafeCancellation +from utils.ui import MessageArgs, HookedItem, AsComponents +from utils.lib import utc_now, jumpto, emojikey +from babel.translator import ctx_locale + +from modules.economy.cog import Economy, EconomyData, TransactionType from .data import RoleMenuData as Data +from .data import MenuType from .menuoptions import RoleMenuConfig from .roleoptions import RoleMenuRoleConfig +from .templates import templates +from . import logger, babel + +if TYPE_CHECKING: + from .cog import RoleMenuCog + +_p = babel._p + +MISSING = object() + +DEFAULT_EMOJIS = "🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🫐 🍓 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🫒 🥦 🥬 🫑 🥒".split() +DEFAULT_EMOJIS_PARTIALS = [discord.PartialEmoji(name=string) for string in DEFAULT_EMOJIS] + + +class MenuDropdown(HookedItem, Select): + ... + + +class MenuButton(HookedItem, Button): + ... class RoleMenuRole: @@ -12,31 +47,678 @@ class RoleMenuRole: self.data = data self.config = RoleMenuRoleConfig(data.menuroleid, data) + @property + def custom_id(self): + return f"rmrid:{self.data.menuroleid}" + + @property + def as_option(self): + return SelectOption( + emoji=self.config.emoji.data or None, + label=self.config.label.value, + value=str(self.data.menuroleid), + description=self.config.description.value, + ) + + @property + def as_button(self): + @MenuButton( + emoji=self.config.emoji.data or None, + label=self.config.label.value, + custom_id=self.custom_id, + style=ButtonStyle.grey + ) + async def menu_button(press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + menu = await RoleMenu.fetch(self.bot, self.data.menuid) + await menu.interactive_selection(press, self.data.menuroleid) + + return menu_button + class RoleMenu: - def __init__(self, bot: LionBot, data: Data.RoleMenu, roles): + # Cache of messages with listening menus attached + attached_menus = defaultdict(dict) # guildid -> messageid -> menuid + + # Registry of persistent Views for given menus + menu_views = {} # menuid -> View + + # Persistent cache of menus + _menus = {} # menuid -> Menu + + def __init__(self, bot: LionBot, data: Data.RoleMenu, rolemap): self.bot = bot + self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog') self.data = data self.config = RoleMenuConfig(data.menuid, data) - self.roles: list[RoleMenuRole] = roles + self.rolemap: dict[int, RoleMenuRole] = rolemap + self.roles = list(rolemap.values()) - self._message = None + self._message = MISSING + + @property + def _view(self) -> Optional[discord.ui.View]: + """ + Active persistent View for this menu. + """ + return self.menu_views.get(self.data.menuid, None) @property def message(self): + if self._message is MISSING: + raise ValueError("Cannot access menu message before fetch") + else: + return self._message + + @property + def jump_link(self): + if self.data.messageid: + link = jumpto( + self.data.guildid, + self.data.channelid, + self.data.messageid + ) + else: + link = None + return link + + @property + def managed(self): + """ + Whether the menu message is owned by the bot. + + Returns True if the menu is unattached. + """ + if self._message is MISSING: + # Unknown, but send falsey value + managed = None + elif self._message is None: + managed = True + elif self._message.author is self._message.guild.me: + managed = True + else: + managed = False + return managed + + @classmethod + async def fetch(cls, bot: LionBot, menuid: int): + """ + Fetch the requested menu by id, applying registry cache where possible. + """ + if (menu := cls._menus.get(menuid, None)) is None: + cog = bot.get_cog('RoleMenuCog') + data = await cog.data.RoleMenu.fetch(menuid) + role_rows = await cog.data.RoleMenuRole.fetch_where(menuid=menuid).order_by('menuroleid') + rolemap = {row.menuroleid: RoleMenuRole(bot, row) for row in role_rows} + menu = cls(bot, data, rolemap) + cls._menus[menuid] = menu + return menu + + @classmethod + async def create(cls, bot: LionBot, **data_args): + cog = bot.get_cog('RoleMenuCog') + data = await cog.data.RoleMenu.create( + **data_args + ) + menu = cls(bot, data, {}) + cls._menus[data.menuid] = menu + await menu.attach() + return menu + + async def fetch_message(self, refresh=False): + """ + Fetch the message the menu is attached to. + """ + if refresh or self._message is MISSING: + if self.data.messageid is None: + _message = None + else: + _message = None + channelid = self.data.channelid + channel = self.bot.get_channel(channelid) + if channel is not None: + try: + _message = await channel.fetch_message(self.data.messageid) + except discord.NotFound: + pass + except discord.Forbidden: + pass + except discord.HTTPException: + # Something unexpected went wrong, leave the data alone for now + logger.exception("Something unexpected occurred while fetching the menu message") + raise + if _message is None: + await self.data.update(messageid=None) + self._message = _message return self._message - async def fetch_message(self): - ... + def emoji_map(self): + emoji_map = {} + for mrole in self.roles: + emoji = mrole.config.emoji.as_partial + if emoji is not None: + emoji_map[emoji] = mrole.data.menuroleid + return emoji_map - async def reload(self): - await self.data.refresh() + async def attach(self): + """ + Start listening for menu selection events. + """ + if self.data.messageid: + self.attached_menus[self.data.guildid][self.data.messageid] = self.data.menuid + if self.data.menutype is not MenuType.REACTION: + view = await self.make_view() + if view is not None: + self.menu_views[self.data.menuid] = view + self.bot.add_view(view) + elif self.data.menutype is MenuType.REACTION: + pass + + def detach(self): + """ + Stop listening for menu selection events. + """ + view = self.menu_views.pop(self.data.menuid, None) + if view is not None: + view.stop() + if (mid := self.data.messageid) is not None: + self.attached_menus[self.data.guildid].pop(mid, None) + + async def delete(self): + self.detach() + self._menus.pop(self.data.menuid, None) + + # Delete the menu, along with the message if it is self-managed. + message = await self.fetch_message() + if message and message.author is message.guild.me: + try: + await message.delete() + except discord.HTTPException: + # This should never really fail since we own the message + # But it is possible the message was externally deleted and we never updated message cache + # So just ignore quietly + pass + + # Cancel any relevant expiry tasks (before we delete data which will delete the equip rows) + expiring = await self.cog.data.RoleMenuHistory.fetch_expiring_where(menuid=self.data.menuid) + if expiring: + await self.cog.cancel_expiring_tasks(*(row.equipid for row in expiring)) + await self.data.delete() + + async def reload_roles(self): + """ + Fetches and re-initialises the MenuRoles for this Menu. + """ 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] + role_rows = await roledata.fetch_where(menuid=self.data.menuid).order_by('menuroleid') + self.rolemap = {row.menuroleid: RoleMenuRole(self.bot, row) for row in role_rows} + self.roles = list(self.rolemap.values()) - async def make_view(self): - ... + async def update_message(self): + """ + Update the (managed) message the menu is attached to. - async def make_args(self): - ... + Does nothing if there is not message or it is not bot-managed. + """ + self.detach() + message = await self.fetch_message() + if message is not None and self.managed: + args = await self.make_args() + view = await self.make_view() + try: + await message.edit(**args.edit_args, view=view) + await self.attach() + except discord.NotFound: + await self.data.update(messageid=None) + self._message = None + except discord.HTTPException as e: + t = self.bot.translator.t + error = discord.Embed( + colour=discord.Colour.brand_red(), + title=t(_p( + 'rolemenu|menu_message|error|title', + 'ROLE MENU DISPLAY ERROR' + )), + description=t(_p( + 'rolemenu|menu_message|error|desc', + "A critical error occurred trying to display this role menu.\n" + "Error: `{error}`." + )).format(error=e.text) + ) + try: + await message.edit( + embed=error + ) + except discord.HTTPException: + # There's really something wrong + # Nothing we can safely do. + pass + pass + + async def update_reactons(self): + """ + Attempt to update the reactions on a REACTION type menu. + + Does nothing if the menu is not REACTION type. + Will raise `SafeCancellation` and stop if a reaction fails. + """ + message = await self.fetch_message() + if message is not None and self.data.menutype is MenuType.REACTION: + # First remove any of my reactions that are no longer relevant + required = { + emojikey(mrole.config.emoji.as_partial) for mrole in self.roles if mrole.data.emoji + } + for reaction in message.reactions: + if reaction.me and (emojikey(reaction.emoji) not in required): + try: + await message.remove_reaction(reaction.emoji, message.guild.me) + except discord.HTTPException: + pass + + # Then add any extra reactions that are missing + existing_mine = { + emojikey(reaction.emoji) for reaction in message.reactions if reaction.me + } + existing = { + emojikey(reaction.emoji) for reaction in message.reactions + } + for mrole in self.roles: + emoji = mrole.config.emoji.as_partial + if emoji is not None and emojikey(emoji) not in existing_mine: + try: + await message.add_reaction(emoji) + except discord.HTTPException: + if emojikey(emoji) not in existing: + t = self.bot.translator.t + raise SafeCancellation( + t(_p( + 'rolemenu|update_reactions|error', + "Could not add the {emoji} reaction, perhaps I do not " + "have access to this emoji! Reactions will need to be added " + "manually." + )).format(emoji=emoji) + ) + else: + # We can't react with this emoji, but it does exist on the message + # Just ignore the error and continue + continue + + async def repost_to(self, destination): + # Set the current message to be deleted if it is a managed message. + # Don't delete until after we have successfully moved the menu though. + if self.managed and (message := self.message): + to_delete = message + else: + to_delete = None + + # Now try and post the message in the new channel + args = await self.make_args() + view = await self.make_view() + new_message = await destination.send(**args.send_args, view=view or discord.utils.MISSING) + + # Stop listening to events on the current message (if it exists) + self.detach() + await self.data.update(channelid=destination.id, messageid=new_message.id) + self._message = new_message + await self.attach() + + if to_delete: + # Attempt to delete the original message + try: + await to_delete.delete() + except discord.HTTPException: + pass + + async def _make_button_view(self): + buttons = [mrole.as_button for mrole in self.roles] + return AsComponents(*buttons, timeout=None) + + async def _make_dropdown_view(self): + t = self.bot.translator.t + + placeholder = t(_p( + 'ui:rolemenu_dropdown|placeholder', + "Select Roles" + )) + options = [mrole.as_option for mrole in self.roles] + + @MenuDropdown( + custom_id=f"menuid:{self.data.menuid}", + placeholder=placeholder, + options=options, + min_values=0, max_values=1 + ) + async def menu_dropdown(selection: discord.Interaction, selected: Select): + if selected.values: + await selection.response.defer(thinking=True, ephemeral=True) + menuroleid = int(selected.values[0]) + menu = await self.fetch(self.bot, self.data.menuid) + await menu.interactive_selection(selection, menuroleid) + else: + await selection.response.defer(thinking=False) + + return AsComponents(menu_dropdown, timeout=None) + + async def make_view(self) -> Optional[discord.ui.View]: + """ + Create the appropriate discord.View for this menu. + + May be None if the menu has no roles or is a REACTION menu. + """ + lguild = await self.bot.core.lions.fetch_guild(self.data.guildid) + ctx_locale.set(lguild.locale) + if not self.roles: + view = None + elif self.data.menutype is MenuType.REACTION: + view = None + elif self.data.menutype is MenuType.DROPDOWN: + view = await self._make_dropdown_view() + elif self.data.menutype is MenuType.BUTTON: + view = await self._make_button_view() + return view + + async def make_args(self) -> MessageArgs: + """ + Generate the message arguments for this menu. + """ + if (tid := self.data.templateid) is not None: + # Apply template + template = templates[tid] + args = await template.render_menu(self) + else: + raw = self.data.rawmessage + data = json.loads(raw) + args = MessageArgs( + content=data.get('content', ''), + embed=discord.Embed.from_dict(data['embed']) if 'embed' in data else None + ) + return args + + def unused_emojis(self, include_defaults=True): + """ + Fetch the next emoji on the message that is not already assigned to a role. + Checks custom emojis by PartialEmoji equality (i.e. by id). + + If no reaction exists, uses a default emoji. + """ + if self.message: + message_emojis = [reaction.emoji for reaction in self.message.reactions] + else: + message_emojis = [] + if self.data.menutype is MenuType.REACTION: + valid_emojis = (*message_emojis, *DEFAULT_EMOJIS_PARTIALS) + else: + valid_emojis = message_emojis + menu_emojis = {emojikey(mrole.config.emoji.as_partial) for mrole in self.roles} + for emoji in valid_emojis: + if emojikey(emoji) not in menu_emojis: + yield str(emoji) + + async def _handle_selection(self, lion, member: discord.Member, menuroleid: int): + mrole = self.rolemap.get(menuroleid, None) + if mrole is None: + raise ValueError(f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN.") + + guild = member.guild + + t = self.bot.translator.t + + role = guild.get_role(mrole.data.roleid) + if role is None: + # This role no longer exists, nothing we can do + raise UserInputError( + t(_p( + 'rolemenu|error:role_gone', + "This role no longer exists!" + )) + ) + if role in member.roles: + # Member already has the role, deselection case. + if self.config.sticky.value: + # Cannot deselect + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:sticky', + "{role} is a sticky role, you cannot remove it with this menu!" + )).format(role=role.mention) + ) + + conn = await self.bot.db.get_connection() + async with conn.transaction(): + # Remove the role + try: + await member.remove_roles(role) + except discord.Forbidden: + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:perms', + "I don't have enough permissions to remove this role from you!" + )) + ) + except discord.HTTPException: + raise UserInputError( + t(_p( + 'rolemenu|deselect|error:discord', + "An unknown error occurred removing your role! Please try again later." + )) + ) + + # Update history + now = utc_now() + history = await self.cog.data.RoleMenuHistory.table.update_where( + menuid=self.data.menuid, + roleid=role.id, + userid=member.id, + removed_at=None, + ).set(removed_at=now) + await self.cog.cancel_expiring_tasks(*(row.equipid for row in history)) + + # Refund if required + transactionids = [row['transactionid'] for row in history] + if self.config.refunds.value and any(transactionids): + transactionids = [tid for tid in transactionids if tid] + economy: Economy = self.bot.get_cog('Economy') + refunded = await economy.data.Transaction.refund_transactions(*transactionids) + total_refund = sum(row.amount + row.bonus for row in refunded) + else: + total_refund = 0 + + # Ack the removal + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'rolemenu|deslect|success|title', + "Role removed" + )) + ) + if total_refund: + embed.description = t(_p( + 'rolemenu|deselect|success:refund|desc', + "You have removed {role}, and been refunded {coin} **{amount}**." + )).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=total_refund) + else: + embed.description = t(_p( + 'rolemenu|deselect|success:norefund|desc', + "You have unequipped {role}." + )).format(role=role.mention) + return embed + else: + # Member does not have the role, selection case. + required = self.config.required_role.value + if required is not None: + # Check member has the required role + if required not in member.roles: + raise UserInputError( + t(_p( + 'rolemenu|select|error:required_role', + "You need to have the {role} role to use this!" + )).format(role=required.mention) + ) + + obtainable = self.config.obtainable.value + if obtainable is not None: + # Check shared roles + menu_roleids = {mrole.data.roleid for mrole in self.roles} + member_roleids = {role.id for role in member.roles} + common = len(menu_roleids.intersection(member_roleids)) + if common >= obtainable: + raise UserInputError( + t(_p( + 'rolemenu|select|error:max_obtainable', + "You already have the maximum of {obtainable} roles from this menu!" + )).format(obtainable=obtainable) + ) + + price = mrole.config.price.value + if price: + # Check member balance + # TODO: More transaction safe (or rather check again after transaction) + await lion.data.refresh() + balance = lion.data.coins + if balance < price: + raise UserInputError( + t(_p( + 'rolemenu|select|error:insufficient_funds', + "The role {role} costs {coin}**{cost}**," + "but you only have {coin}**{balance}**!" + )).format( + role=role.mention, + coin=self.bot.config.emojis.coin, + cost=price, + balance=balance, + ) + ) + + conn = await self.bot.db.get_connection() + async with conn.transaction(): + try: + await member.add_roles(role) + except discord.Forbidden: + raise UserInputError( + t(_p( + 'rolemenu|select|error:perms', + "I don't have enough permissions to give you this role!" + )) + ) + except discord.HTTPException: + raise UserInputError( + t(_p( + 'rolemenu|select|error:discord', + "An unknown error occurred while assigning your role! " + "Please try again later." + )) + ) + + now = utc_now() + + # Create transaction if applicable + if price: + economy: Economy = self.bot.get_cog('Economy') + tx = await economy.data.Transaction.execute_transaction( + transaction_type=TransactionType.OTHER, + guildid=guild.id, actorid=member.id, + from_account=member.id, to_account=None, + amount=price + ) + tid = tx.transactionid + else: + tid = None + + # Calculate expiry + duration = mrole.config.duration.value + if duration is not None: + expiry = now + dt.timedelta(seconds=duration) + else: + expiry = None + + # Add to equip history + equip = await self.cog.data.RoleMenuHistory.create( + menuid=self.data.menuid, roleid=role.id, + userid=member.id, + obtained_at=now, + transactionid=tid, + expires_at=expiry + ) + await self.cog.schedule_expiring(equip) + + # Ack the selection + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'rolemenu|select|success|title', + "Role equipped" + )) + ) + if price > 0: + embed.description = t(_p( + 'rolemenu|select|success:purchase|desc', + "You have purchased the role {role} for {coin}**{amount}**" + )).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=price) + else: + embed.description = t(_p( + 'rolemenu|select|success:nopurchase|desc', + "You have equipped the role {role}" + )).format(role=role.mention) + + if expiry is not None: + embed.description += '\n' + t(_p( + 'rolemenu|select|expires_at', + "The role will expire at {timestamp}." + )).format( + timestamp=discord.utils.format_dt(expiry) + ) + return embed + + async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int): + """ + Handle a component interaction callback for this menu. + + Assumes the interaction has already been responded to (ephemerally). + """ + member = interaction.user + guild = interaction.guild + if not isinstance(member, discord.Member): + # Occasionally Discord drops the ball on user type. This manually fetches the guild member. + member = await guild.fetch_member(member.id) + + # Localise to the member's locale + lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member) + ctx_locale.set(lion.private_locale(interaction)) + result = await self._handle_selection(lion, member, menuroleid) + await interaction.edit_original_response(embed=result) + + async def handle_reaction(self, reaction_payload: discord.RawReactionActionEvent): + """ + Handle a raw reaction event on a message the menu is attached to. + + Ignores the event if it is not relevant. + """ + guild = self.bot.get_guild(reaction_payload.guild_id) + channel = self.bot.get_channel(reaction_payload.channel_id) + if guild and channel: + emoji_map = self.emoji_map() + menuroleid = emoji_map.get(reaction_payload.emoji, None) + if menuroleid is not None: + member = reaction_payload.member + if not member: + member = await guild.fetch_member(reaction_payload.user_id) + if member.bot: + return + lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member) + ctx_locale.set(lion.private_locale()) + try: + embed = await self._handle_selection(lion, member, menuroleid) + except UserInputError as e: + embed = discord.Embed( + colour=discord.Colour.brand_red(), + description=e.msg + ) + t = self.bot.translator.t + content = t(_p( + 'rolemenu|content:reactions', + "[Click here]({jump_link}) to jump back." + )).format(jump_link=jumpto(guild.id, channel.id, reaction_payload.message_id)) + try: + await member.send(content=content, embed=embed) + except discord.HTTPException: + pass diff --git a/src/modules/rolemenus/roleoptions.py b/src/modules/rolemenus/roleoptions.py index 4ef2d5b2..e3ab699c 100644 --- a/src/modules/rolemenus/roleoptions.py +++ b/src/modules/rolemenus/roleoptions.py @@ -1,9 +1,14 @@ +import discord + from settings import ModelData from settings.groups import SettingGroup, ModelConfig, SettingDotDict from settings.setting_types import ( - RoleSetting, BoolSetting, StringSetting, DurationSetting + RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting ) from core.setting_types import CoinSetting +from utils.ui import AButton, AsComponents +from meta.errors import UserInputError +from babel.translator import ctx_translator from .data import RoleMenuData from . import babel @@ -59,26 +64,49 @@ class RoleMenuRoleOptions(SettingGroup): _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.roleid.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'roleset:role|set_response:set', + "This menu item will now give the role {role}." + )).format(role=self.formatted) + return resp + @RoleMenuRoleConfig.register_model_setting class Label(ModelData, StringSetting): - setting_id = 'role' + setting_id = 'label' _display_name = _p('roleset:label', "label") _desc = _p( 'roleset:label|desc', "A short button label for this role." ) + _accepts = _desc _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." ) + _quote = False + _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.label.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + resp = t(_p( + 'roleset:role|set_response', + "This menu role is now called `{value}`." + )).format(value=self.data) + return resp + @RoleMenuRoleConfig.register_model_setting - class Emoji(ModelData, StringSetting): + class Emoji(ModelData, EmojiSetting): setting_id = 'emoji' _display_name = _p('roleset:emoji', "emoji") @@ -96,6 +124,56 @@ class RoleMenuRoleOptions(SettingGroup): _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.emoji.name + @property + def test_button(self): + if self.data: + button = AButton(emoji=self.data) + button.disabled = True + + @button + async def emoji_test_callback(press, butt): + await press.response.defer() + else: + button = None + return button + + @classmethod + async def _parse_string(cls, parent_id, string: str, interaction: discord.Interaction = None, **kwargs): + emojistr = await super()._parse_string(parent_id, string, interaction=interaction, **kwargs) + if emojistr and interaction is not None: + # Use the interaction to test + button = AButton(emoji=emojistr) + button.disabled = True + view = AsComponents(button) + try: + await interaction.edit_original_response( + content=f"Testing Emoji {emojistr}", + view=view, + ) + except discord.HTTPException: + t = interaction.client.translator.t + raise UserInputError(t(_p( + 'roleset:emoji|error:test_emoji', + "The selected emoji `{emoji}` is invalid or has been deleted." + )).format(emoji=emojistr)) + return emojistr + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'roleset:emoji|set_response:set', + "The menu role emoji is now {emoji}." + )).format(emoji=self.as_partial) + else: + resp = t(_p( + 'roleset:emoji|set_response:unset', + "The menu role emoji has been removed." + )) + return resp + @RoleMenuRoleConfig.register_model_setting class Description(ModelData, StringSetting): setting_id = 'description' @@ -105,15 +183,34 @@ class RoleMenuRoleOptions(SettingGroup): 'roleset:description|desc', "A longer description of this role." ) + _accepts = _desc _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." ) + _quote = False + _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.description.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'roleset:description|set_response:set', + "The role description has been set." + )) + else: + resp = t(_p( + 'roleset:description|set_response:unset', + "The role description has been removed." + )) + return resp + @RoleMenuRoleConfig.register_model_setting class Price(ModelData, CoinSetting): setting_id = 'price' @@ -127,10 +224,30 @@ class RoleMenuRoleOptions(SettingGroup): 'roleset:price|long_desc', "How much the role costs when selected, in LionCoins." ) + _accepts = _p( + 'roleset:price|accepts', + "Amount of coins that the role costs." + ) _default = 0 _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.price.name + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'roleset:price|set_response:set', + "This role will now cost {price} to equip." + )).format(price=self.formatted) + else: + resp = t(_p( + 'roleset:price|set_response:unset', + "This role will now be free to equip from this role menu." + )) + return resp + @RoleMenuRoleConfig.register_model_setting class Duration(ModelData, DurationSetting): setting_id = 'duration' @@ -145,5 +262,26 @@ class RoleMenuRoleOptions(SettingGroup): "Allows creation of 'temporary roles' which expire a given time after being equipped. " "Refunds will not be given upon expiry." ) + _notset_str = _p( + 'roleset:duration|notset', + "Forever." + ) + _model = RoleMenuData.RoleMenuRole _column = RoleMenuData.RoleMenuRole.duration.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value: + resp = t(_p( + 'roleset:duration|set_response:set', + "This role will now expire after {duration}." + )).format(duration=self.formatted) + else: + resp = t(_p( + 'roleset:duration|set_response:unset', + "This role will no longer expire after being selected." + )) + return resp diff --git a/src/modules/rolemenus/templates.py b/src/modules/rolemenus/templates.py index eeefa390..3c93017b 100644 --- a/src/modules/rolemenus/templates.py +++ b/src/modules/rolemenus/templates.py @@ -13,7 +13,7 @@ _p = babel._p DEFAULT_EMOJI = '🔲' -templates = {} +templates: dict[int, 'Template'] = {} class Template: @@ -68,7 +68,7 @@ async def simple_template(menu) -> MessageArgs: duration = menurole.config.duration if emoji.data: - parts.append(emoji.formatted) + parts.append(emoji.data) parts.append(role.formatted) @@ -114,7 +114,7 @@ async def twocolumn_template(menu) -> MessageArgs: ) for block in blocks: block_lines = [ - f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}" + f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**" for menurole in block ] if block_lines: @@ -151,7 +151,7 @@ async def threecolumn_template(menu) -> MessageArgs: ) for block in blocks: block_lines = [ - f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}" + f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**" for menurole in block ] if block_lines: @@ -188,7 +188,7 @@ async def shop_template(menu) -> MessageArgs: parts.append("|") if emoji.data: - parts.append(emoji.formatted) + parts.append(emoji.data) parts.append(role.formatted) diff --git a/src/modules/rolemenus/ui/menueditor.py b/src/modules/rolemenus/ui/menueditor.py index 623f7981..e38b15a2 100644 --- a/src/modules/rolemenus/ui/menueditor.py +++ b/src/modules/rolemenus/ui/menueditor.py @@ -8,9 +8,12 @@ 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 meta.errors import UserInputError, ResponseTimedOut, SafeCancellation +from utils.lib import utc_now, MessageArgs, error_embed, tabulate +from utils.ui import ( + MessageUI, ConfigEditor, FastModal, error_handler_for, + ModalRetryUI, MsgEditor, Confirm, HookedItem, AsComponents, +) from babel.translator import ctx_locale from wards import equippable_role @@ -32,6 +35,10 @@ class RoleEditorInput(FastModal): await ModalRetryUI(self, error.msg).respond_to(interaction) +class AChannelSelect(HookedItem, ChannelSelect): + ... + + class EditorMode(Enum): OPTIONS = 0 ROLES = 1 @@ -39,6 +46,8 @@ class EditorMode(Enum): class MenuEditor(MessageUI): + _listening = {} # (channelid, callerid) -> active MenuEditor + def _init_children(self): # HACK to stop ViewWeights complaining that this UI has too many children # Children will be correctly initialised after parent init. @@ -51,40 +60,76 @@ class MenuEditor(MessageUI): self.bot = bot self.menu = menu self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data + self.listen_key = None # UI State self.mode: EditorMode = EditorMode.ROLES + self.page_count: int = 1 self.pagen: int = 0 + self.page_block: list[RoleMenuRole] = [] self._preview: Optional[discord.Interaction] = None # ----- UI API ----- - async def dispatch_update(self): + async def update_preview(self): """ - Broadcast that the menu has changed. - - This updates the preview, and tells the menu itself to update any linked messages. + Update the preview message if it exists. """ - await self.menu.reload() if self._preview is not None: - args = await self._preview_args() + args = await self.menu.make_args() + view = await self.menu.make_view() try: - await self._preview.edit_original_response(**args.edit_args) + await self._preview.edit_original_response(**args.edit_args, view=view) except discord.NotFound: self._preview = None + except discord.HTTPException as e: + # Due to emoji validation on creation and message edit validation, + # This should be very rare. + # Might happen if e.g. a custom emoji is deleted between opening the editor + # and showing the preview. + # Just show the error to the user and let them deal with it or rerun the editor. + t = self.bot.translator.t + title = t(_p( + 'ui:menu_editor|preview|error:title', + "Display Error!" + )) + desc = t(_p( + 'ui:menu_editor|preview|error:desc', + "Failed to display preview!\n" + "**Error:** `{exception}`" + )).format( + exception=e.text + ) + embed = discord.Embed( + colour=discord.Colour.brand_red(), + title=title, + description=desc + ) + try: + await self._preview.edit_original_response(embed=embed) + except discord.HTTPException: + # If we can't even edit the preview message now, something is probably wrong with the connection + # Just silently ignore + pass - 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 + async def cleanup(self): + self._listening.pop(self.listen_key, None) + await super().cleanup() + + async def run(self, interaction: discord.Interaction, **kwargs): + self.listen_key = (interaction.channel.id, interaction.user.id, self.menu.data.menuid) + existing = self._listening.get(self.listen_key, None) + if existing: + await existing.quit() + self._listening[self.listen_key] = self + await super().run(interaction, **kwargs) + + async def quit(self): + if self._preview is not None and not self._preview.is_expired(): + try: + await self._preview.delete_original_response() + except discord.HTTPException: + pass + await super().quit() # ----- Components ----- # -- Options Components -- @@ -154,8 +199,6 @@ class MenuEditor(MessageUI): # 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: @@ -182,7 +225,6 @@ class MenuEditor(MessageUI): 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): @@ -207,7 +249,6 @@ class MenuEditor(MessageUI): 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): @@ -215,7 +256,7 @@ class MenuEditor(MessageUI): button = self.refunds_button button.label = t(_p( 'ui:menu_editor|button:refunds|label', - "Refunds" + "Toggle Refunds" )) if self.menu.config.refunds.value: button.style = ButtonStyle.blurple @@ -238,7 +279,6 @@ class MenuEditor(MessageUI): 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): @@ -300,6 +340,7 @@ class MenuEditor(MessageUI): @modal.submit_callback() async def save_options(interaction: discord.Interaction): + await interaction.response.defer(thinking=True, ephemeral=True) modified = [] for instance, field, original in zip(instances, fields, originals): if field.value != original: @@ -308,25 +349,32 @@ class MenuEditor(MessageUI): if not userstr: new_data = None else: - new_data = await instance._parse_string(instance.parent_id, userstr) + new_data = await instance._parse_string(instance.parent_id, userstr, interaction=interaction) 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) + await self.update_preview() + await self.menu.update_message() + if self.menu.data.menutype is MenuType.REACTION: + try: + await self.menu.update_reactons() + except SafeCancellation as e: + await interaction.followup.send( + embed=discord.Embed( + colour=discord.Colour.brand_red(), + description=e.msg + ), + ephemeral=True + ) else: - # Nothing was modified, quietly accept - await interaction.response.defer(thinking=False) + await interaction.delete_original_response() 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) @@ -369,21 +417,35 @@ class MenuEditor(MessageUI): ))) # Create roles - # TODO: Emoji generation + emojis = self.menu.unused_emojis(include_defaults=(self.menu.data.menutype is MenuType.REACTION)) 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()) + ('menuid', 'roleid', 'label', 'emoji'), + *( + (self.menu.data.menuid, role.id, role.name[:100], next(emojis, None)) + 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() + + await self.menu.reload_roles() + if self.menu.data.name == 'Untitled': + # Hack to name an anonymous menu + # TODO: Formalise this + await self.menu.data.update(name=roles[0].name) + await self.refresh() + await self.update_preview() + await self.menu.update_message() + if self.menu.data.menutype is MenuType.REACTION: + try: + await self.menu.update_reactons() + except SafeCancellation as e: + raise UserInputError(e.msg) async def add_roles_menu_refresh(self): t = self.bot.translator.t @@ -395,6 +457,7 @@ class MenuEditor(MessageUI): def _role_option(self, menurole: RoleMenuRole): return SelectOption( + emoji=menurole.config.emoji.data or None, label=menurole.config.label.value, value=str(menurole.data.menuroleid), description=menurole.config.description.value, @@ -435,7 +498,11 @@ class MenuEditor(MessageUI): if menuroleids: await selection.response.defer(thinking=True, ephemeral=True) await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids) - await self.dispatch_update() + + await self.menu.reload_roles() + await self.refresh(thinking=selection) + await self.update_preview() + await self.menu.update_message() else: await selection.response.defer(thinking=False) @@ -468,7 +535,7 @@ class MenuEditor(MessageUI): 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." + "Cannot change the style of a menu attached to a message I did not send! Please repost first." )) ) @@ -495,8 +562,8 @@ class MenuEditor(MessageUI): Select one of Reaction Roles / Dropdown / Button """ t = self.bot.translator.t - value = int(selected.values[0]) - menutype = MenuType(value) + value = selected.values[0] + menutype = MenuType[value] if menutype is not self.menu.data.menutype: # A change is requested if menutype is MenuType.REACTION: @@ -521,8 +588,9 @@ class MenuEditor(MessageUI): ) await selection.response.defer(thinking=True, ephemeral=True) await self.menu.data.update(menutype=menutype) - await self.dispatch_update() await self.refresh(thinking=selection) + await self.update_preview() + await self.menu.update_message() else: await selection.response.defer() @@ -540,7 +608,7 @@ class MenuEditor(MessageUI): 'ui:menu_editor|menu:style|option:reaction|desc', "Roles are represented compactly as clickable reactions on a message." )), - value=str(MenuType.REACTION.value), + value=str(MenuType.REACTION.name), default=(self.menu.data.menutype is MenuType.REACTION) ), SelectOption( @@ -549,7 +617,7 @@ class MenuEditor(MessageUI): '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), + value=str(MenuType.BUTTON.name), default=(self.menu.data.menutype is MenuType.BUTTON) ), SelectOption( @@ -558,7 +626,7 @@ class MenuEditor(MessageUI): 'ui:menu_editor|menu:style|option:dropdown|desc', "Roles are selectable from a dropdown menu below the message." )), - value=str(MenuType.DROPDOWN.value), + value=str(MenuType.DROPDOWN.name), default=(self.menu.data.menutype is MenuType.DROPDOWN) ) ] @@ -566,10 +634,12 @@ class MenuEditor(MessageUI): async def _editor_callback(self, new_data): raws = json.dumps(new_data) await self.menu.data.update(rawmessage=raws) - await self.dispatch_update() + await self.update_preview() + await self.menu.update_message() async def _message_editor(self, interaction: discord.Interaction): # Spawn the message editor with the current rawmessage data. + # If the rawmessage data is empty, use the current template instead. editor = MsgEditor( self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid ) @@ -608,12 +678,14 @@ class MenuEditor(MessageUI): # Spawn editor await self._message_editor(selection) - await self.dispatch_update() await self.refresh() + await self.update_preview() + await self.menu.update_message() else: await self.menu.data.update(templateid=templateid) - await self.dispatch_update() await self.refresh(thinking=selection) + await self.update_preview() + await self.menu.update_message() else: await selection.response.defer() @@ -646,24 +718,122 @@ class MenuEditor(MessageUI): # -- Common Components -- # Delete Menu Button - # Quit Button + @button(label="DELETE_BUTTON_PLACEHOLDER", style=ButtonStyle.red) + async def delete_button(self, press: discord.Interaction, pressed: Button): + """ + Confirm menu deletion, and delete. + """ + t = self.bot.translator.t + confirm_msg = t(_p( + 'ui:menu_editor|button:delete|confirm|title', + "Are you sure you want to delete this menu? This is not reversible!" + )) + confirm = Confirm(confirm_msg, self._callerid) + confirm.confirm_button.label = t(_p( + 'ui:menu_editor|button:delete|confirm|button:yes', + "Yes, Delete Now" + )) + confirm.confirm_button.style = ButtonStyle.red + confirm.cancel_button.label = t(_p( + 'ui:menu_editor|button:delete|confirm|button:no', + "No, Go Back" + )) + confirm.cancel_button.style = ButtonStyle.green + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + await self.menu.delete() + await self.quit() + + async def delete_button_refresh(self): + t = self.bot.translator.t + button = self.delete_button + button.label = t(_p( + 'ui:menu_editor|button:delete|label', + "Delete Menu" + )) + + # Quit Button + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Close the UI. This should also close all children. + """ + await press.response.defer(thinking=False) + await self.quit() + + # Page Buttons + @button(emoji=conf.emojis.forward) + async def next_page_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.pagen += 1 + await self.refresh() + + @button(emoji=conf.emojis.backward) + async def prev_page_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.pagen -= 1 + await self.refresh() - # Page left Button # Edit Message Button + @button(label="EDIT_MSG_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_msg_button(self, press: discord.Interaction, pressed: Button): + # Set the templateid to None if it isn't already + # And initialise the rawmessage if it needs it. + if (templateid := self.menu.data.templateid) is not None: + update_args = {'templateid': None} + if not self.menu.data.rawmessage: + template = templates[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 + + await self.menu.data.update(**update_args) + + # At this point we are certain the menu is in custom mode and has a rawmessage + # Spawn editor + await self._message_editor(press) + await self.refresh() + await self.update_preview() + await self.menu.update_message() + + async def edit_msg_button_refresh(self): + t = self.bot.translator.t + button = self.edit_msg_button + button.label = t(_p( + 'ui:menu_editor|button:edit_msg|label', + "Edit Message" + )) + # Disable the button if we are on a non-managed message + button.disabled = not self.menu.managed + # 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() + args = await self.menu.make_args() + view = await self.menu.make_view() 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) + await press.response.send_message( + **args.send_args, + view=view or discord.utils.MISSING, + ephemeral=True + ) self._preview = press async def preview_button_refresh(self): @@ -675,25 +845,237 @@ class MenuEditor(MessageUI): )) # Repost Menu Button + @button(label="REPOST_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) + async def repost_button(self, press: discord.Interaction, pressed: Button): + """ + Repost the menu in a selected channel. + + Pops up a minimal channel selection UI, asking where they want to post it. + """ + t = self.bot.translator.t + + @AChannelSelect( + placeholder=t(_p( + 'ui:menu_editor|button:repost|widget:repost|menu:channel|placeholder', + "Select New Channel" + )), + channel_types=[discord.ChannelType.text, discord.ChannelType.voice], + min_values=1, max_values=1 + ) + async def repost_widget(selection: discord.Interaction, selected: ChannelSelect): + channel = selected.values[0].resolve() if selected.values else None + if channel is None: + await selection.response.defer() + else: + # Valid channel selected, do the repost + await selection.response.defer(thinking=True, ephemeral=True) + + try: + await self.menu.repost_to(channel) + except discord.Forbidden: + title = t(_p( + 'ui:menu_editor|button:repost|widget:repost|error:perms|title', + "Insufficient Permissions!" + )) + desc = t(_p( + 'ui:menu_editor|button:repost|eidget:repost|error:perms|desc', + "I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission in this channel." + )) + embed = discord.Embed( + colour=discord.Colour.brand_red(), + title=title, + description=desc + ) + await selection.edit_original_response(embed=embed) + except discord.HTTPException: + error = discord.Embed( + colour=discord.Colour.brand_red(), + description=t(_p( + 'ui:menu_editor|button:repost|widget:repost|error:post_failed', + "An error ocurred while posting to {channel}. Do I have sufficient permissions?" + )).format(channel=channel.mention) + ) + await selection.edit_original_response(embed=error) + else: + try: + await press.delete_original_response() + except discord.HTTPException: + pass + + success_title = t(_p( + 'ui:menu_editor|button:repost|widget:repost|success|title', + "Role Menu Moved" + )) + desc_lines = [] + desc_lines.append( + t(_p( + 'ui:menu_editor|button:repost|widget:repost|success|desc:general', + "The role menu `{name}` is now available at {message_link}." + )).format( + name=self.menu.data.name, + message_link=self.menu.message.jump_url, + ) + ) + if self.menu.data.menutype is MenuType.REACTION: + try: + await self.menu.update_reactons() + except SafeCancellation as e: + desc_lines.append(e.msg) + else: + t(_p( + 'ui:menu_editor|button:repost|widget:repost|success|desc:reactions', + "Please check the message reactions are correct." + )) + await selection.edit_original_response( + embed=discord.Embed( + title=success_title, + description='\n'.join(desc_lines), + colour=discord.Colour.brand_green(), + ) + ) + + # Create the selection embed + title = t(_p( + 'ui:menu_editor|button:repost|widget:repost|title', + "Repost Role Menu" + )) + desc = t(_p( + 'ui:menu_editor|button:repost|widget:repost|description', + "Please select the channel to which you want to resend this menu." + )) + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title, description=desc + ) + # Send as response with the repost widget attached + await press.response.send_message(embed=embed, view=AsComponents(repost_widget)) + + async def repost_button_refresh(self): + t = self.bot.translator.t + button = self.repost_button + if self.menu.message is not None: + button.label = t(_p( + 'ui:menu_editor|button:repost|label:repost', + "Repost" + )) + else: + button.label = t(_p( + 'ui:menu_editor|button:repost|label:post', + "Post" + )) # ----- UI Flow ----- async def make_message(self) -> MessageArgs: t = self.bot.translator.t + # TODO: Link to actual message title = t(_p( 'ui:menu_editor|embed|title', - "'{name}' Role Menu Editor" + "Role Menu Editor" )).format(name=self.menu.config.name.value) table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid) + jump = self.menu.jump_link + if jump: + jump_text = t(_p( + 'ui:menu_editor|embed|description|jump_text:attached', + "Members may use this menu from {jump_url}" + )).format(jump_url=jump) + else: + jump_text = t(_p( + 'ui:menu_editor|embed|description|jump_text:unattached', + "This menu is not currently active!\n" + "Make it available by clicking `Post` below." + )) + embed = discord.Embed( colour=discord.Colour.orange(), title=title, - description=table + description=jump_text + '\n' + table ) + # Tip field + embed.add_field( + inline=False, + name=t(_p( + 'ui:menu_editor|embed|field:tips|name', + "Command Tips" + )), + value=t(_p( + 'ui:menu_editor|embed|field:tips|value', + "Use the following commands for faster menu setup.\n" + "{menuedit} to edit the above menu options.\n" + "{addrole} to add new roles (recommended for roles with emojis).\n" + "{editrole} to edit role options." + )).format( + menuedit=self.bot.core.mention_cmd('rolemenu editmenu'), + addrole=self.bot.core.mention_cmd('rolemenu addrole'), + editrole=self.bot.core.mention_cmd('rolemenu editrole'), + ) + ) + + # Compute and add the pages + for mrole in self.page_block: + name = f"{mrole.config.label.formatted}" + prop_map = { + mrole.config.emoji.display_name: mrole.config.emoji.formatted, + mrole.config.price.display_name: mrole.config.price.formatted, + mrole.config.duration.display_name: mrole.config.duration.formatted, + mrole.config.description.display_name: mrole.config.description.formatted, + } + prop_table = '\n'.join(tabulate(*prop_map.items())) + value = f"{mrole.config.role.formatted}\n{prop_table}" + + embed.add_field(name=name, value=value, inline=True) + return MessageArgs(embed=embed) + async def _handle_invalid_emoji(self, error: discord.HTTPException): + t = self.bot.translator.t + + text = error.text + splits = text.split('.') + i = splits.index('emoji') + role_index = int(splits[i-1]) + mrole = self.menu.roles[role_index] + + error = discord.Embed( + colour=discord.Colour.brand_red(), + title=t(_p( + 'ui:menu_editor|error:invald_emoji|title', + "Invalid emoji encountered." + )), + description=t(_p( + 'ui:menu_editor|error:invalid_emoji|desc', + "The emoji `{emoji}` for menu role `{label}` no longer exists, unsetting." + )).format(emoji=mrole.config.emoji.data, label=mrole.config.label.data) + ) + await mrole.data.update(emoji=None) + await self.channel.send(embed=error) + + async def _redraw(self, args): + try: + await super()._redraw(args) + except discord.HTTPException as e: + if e.code == 50035 and 'Invalid emoji' in e.text: + await self._handle_invalid_emoji(e) + await self.refresh() + await self.update_preview() + await self.menu.update_message() + else: + raise e + + async def draw(self, *args, **kwargs): + try: + await super().draw(*args, **kwargs) + except discord.HTTPException as e: + if e.code == 50035 and 'Invalid emoji' in e.text: + await self._handle_invalid_emoji(e) + await self.draw(*args, **kwargs) + await self.menu.update_message() + else: + raise e + async def refresh_layout(self): to_refresh = ( self.options_button_refresh(), @@ -709,15 +1091,20 @@ class MenuEditor(MessageUI): self.style_menu_refresh(), self.template_menu_refresh(), self.preview_button_refresh(), + self.delete_button_refresh(), + self.edit_msg_button_refresh(), + self.repost_button_refresh(), ) await asyncio.gather(*to_refresh) - line_1 = ( - self.options_button, self.modify_roles_button, self.style_button, - ) line_last = ( - self.preview_button, + self.options_button, self.modify_roles_button, self.style_button, self.delete_button, self.quit_button ) + line_1 = ( + self.preview_button, self.edit_msg_button, self.repost_button, + ) + if self.page_count > 1: + line_1 = (self.prev_page_button, *line_last, self.next_page_button) if self.mode is EditorMode.OPTIONS: self.set_layout( line_1, @@ -742,4 +1129,10 @@ class MenuEditor(MessageUI): ) async def reload(self): - ... + mroles = self.menu.roles + page_size = 6 + blocks = [mroles[i:i+page_size] for i in range(0, len(mroles), page_size)] or [[]] + self.page_count = len(blocks) + self.pagen = self.pagen % self.page_count + self.page_block = blocks[self.pagen] + await self.menu.fetch_message() diff --git a/src/modules/rolemenus/ui/menus.py b/src/modules/rolemenus/ui/menus.py index e69de29b..7662891c 100644 --- a/src/modules/rolemenus/ui/menus.py +++ b/src/modules/rolemenus/ui/menus.py @@ -0,0 +1,248 @@ +import asyncio +from typing import Optional, TYPE_CHECKING +from collections import defaultdict + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, SelectOption + +from meta import LionBot, conf +from utils.lib import MessageArgs +from utils.ui import MessageUI + +from .. import babel +from ..rolemenu import RoleMenu + +from .menueditor import MenuEditor + +if TYPE_CHECKING: + from ..cog import RoleMenuCog + +_p = babel._p + + +class MenuList(MessageUI): + blocklen = 20 + + def __init__(self, bot: LionBot, guild: discord.Guild, **kwargs): + super().__init__(**kwargs) + + self.bot = bot + self.guild = guild + self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog') + + self.pagen = 0 + self.menus = [] + self.menu_blocks = [[]] + + self._menu_editor: Optional[MenuEditor] = None + + @property + def page(self): + self.pagen %= self.page_count + return self.menu_blocks[self.pagen] + + @property + def page_count(self): + return len(self.menu_blocks) + + # ----- UI API ----- + + # ----- Components ----- + # Quit Button + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Close the UI. This should also close all children. + """ + await press.response.defer(thinking=False) + await self.quit() + + # Page Buttons + @button(emoji=conf.emojis.forward) + async def next_page_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.pagen += 1 + await self.refresh() + + @button(emoji=conf.emojis.backward) + async def prev_page_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.pagen -= 1 + await self.refresh() + + @button(emoji=conf.emojis.refresh) + async def refresh_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + await self.refresh() + + # Menu selector + @select(cls=Select, placeholder="EDITMENU_MENU_PLACEHOLDER", min_values=0, max_values=1) + async def editmenu_menu(self, selection: discord.Interaction, selected: Select): + """ + Opens the menu editor for the selected menu. + + Replaces the existing editor, if it exists. + """ + if selected.values: + await selection.response.defer(thinking=True, ephemeral=True) + if self._menu_editor is not None and not self._menu_editor.is_finished(): + await self._menu_editor.quit() + menuid = int(selected.values[0]) + menu = await RoleMenu.fetch(self.bot, menuid) + editor = MenuEditor(self.bot, menu, callerid=self._callerid) + self._menu_editor = editor + self._slaves.append(editor) + await editor.run(selection) + else: + await selection.response.defer() + + async def editmenu_menu_refresh(self): + t = self.bot.translator.t + menu = self.editmenu_menu + menu.placeholder = t(_p( + 'ui:menu_list|menu:editmenu|placeholder', + "Select Menu to Edit" + )) + menus = self.page + if menus: + menu.options = [ + self._format_menu_option(m) for m in menus + ] + menu.disabled = False + else: + menu.options = [ + SelectOption(label='DUMMY') + ] + menu.disabled = True + + # ----- UI Flow ----- + def _format_menu_line(self, menu: RoleMenu) -> str: + """ + Format a provided RoleMenu into a pretty display line. + """ + t = self.bot.translator.t + jump_link = menu.jump_link + if jump_link is not None: + line = t(_p( + 'ui:menu_list|menu_line:attached', + "[`{name}`]({jump_url}) with `{count}` roles." + )).format( + name=menu.config.name.value, + jump_url=jump_link, + count=len(menu.roles) + ) + else: + line = t(_p( + 'ui:menu_list|menu_line:unattached', + "`{name}` with `{count}` roles." + )).format( + name=menu.config.name.value, + count=len(menu.roles) + ) + return line + + def _format_menu_option(self, menu: RoleMenu) -> SelectOption: + """ + Format a provided RoleMenu into a SelectOption. + """ + option = SelectOption( + value=str(menu.data.menuid), + label=menu.config.name.value[:100], + ) + return option + + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + menus = self.page + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=t(_p( + 'ui:menu_list|embed|title', + "Role Menus in {guild}" + )).format(guild=self.guild.name) + ) + + if not menus: + # Empty page message + # Add tips to create menus + tips_name = t(_p( + 'ui:menu_list|embed|field:tips|name', + "Tips" + )) + tips_value = t(_p( + 'ui:menu_list|embed|field:tips|value', + "Right click an existing message or use the `newmenu` command to create a new menu." + )) + embed.add_field(name=tips_name, value=tips_value) + # TODO: Guide image + else: + # Partition menus by channel, without breaking the order + channel_lines = defaultdict(list) + for menu in menus: + channel_lines[menu.data.channelid].append(self._format_menu_line(menu)) + + for channelid, lines in channel_lines.items(): + name = f"<#{channelid}>" if channelid else t(_p( + 'ui:menu_list|embed|field:unattached|name', + "Unattached Menus" + )) + value = '\n'.join(lines) + # Precaution in case all the menu names are really long + value = value[:1024] + embed.add_field( + name=name, value=value, inline=False + ) + + embed.set_footer( + text=t(_p( + 'ui:menu_list|embed|footer:text', + "Click a menu name to jump to the message." + )) + ) + + return MessageArgs(embed=embed) + + async def refresh_layout(self): + refresh_tasks = ( + self.editmenu_menu_refresh(), + ) + await asyncio.gather(*refresh_tasks) + + if len(self.menu_blocks) > 1: + self.prev_page_button.disabled = False + self.next_page_button.disabled = False + else: + self.prev_page_button.disabled = True + self.next_page_button.disabled = True + + self.set_layout( + (self.prev_page_button, self.refresh_button, self.next_page_button, self.quit_button,), + (self.editmenu_menu,), + ) + + def _sort_key(self, menu_data): + message_exists = int(bool(menu_data.messageid)) + channel = self.guild.get_channel(menu_data.channelid) if menu_data.channelid else None + channel_position = channel.position if channel is not None else 0 + # Unattached menus will be ordered by their creation id + messageid = menu_data.messageid or menu_data.menuid + return (message_exists, channel_position, messageid) + + async def reload(self): + # Fetch menu data for this guild + menu_data = await self.cog.data.RoleMenu.fetch_where(guildid=self.guild.id) + + # Order menu data by (message_exists, channel_position, messageid) + sorted_menu_data = sorted(menu_data, key=self._sort_key) + + # Fetch associated menus, load into self.menus + menus = [] + for data in sorted_menu_data: + menu = await RoleMenu.fetch(self.bot, data.menuid) + menus.append(menu) + + self.menus = menus + + self.menu_blocks = [menus[i:i+self.blocklen] for i in range(0, len(menus), self.blocklen)] or [[]] diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index 95941680..694217fa 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -105,6 +105,116 @@ class StringSetting(InteractiveSetting[ParentID, str, str]): return None +class EmojiSetting(InteractiveSetting[ParentID, str, str]): + """ + Setting type representing a stored emoji. + + The emoji is stored in a single string field, and at no time is guaranteed to be a valid emoji. + """ + _accepts = _p('settype:emoji|accepts', "Paste a builtin emoji, custom emoji, or emoji id.") + + @property + def input_formatted(self) -> str: + """ + Return the current data string. + """ + if self._data is not None: + return str(self._data) + else: + return "" + + @classmethod + def _data_from_value(cls, parent_id: ParentID, value, **kwargs): + """ + Return the provided value string as the data string. + """ + return value + + @classmethod + def _data_to_value(cls, id, data, **kwargs): + """ + Return the provided data string as the value string. + """ + return data + + @classmethod + async def _parse_string(cls, parent_id, string: str, **kwargs): + """ + Parse the given user entered emoji string. + + Accepts unicode (builtin) emojis, custom emojis, and custom emoji ids. + """ + t = ctx_translator.get().t + + provided = string + string = string.strip(' :<>') + if string.startswith('a:'): + string = string[2:] + + if not string or string.lower() == 'none': + emojistr = None + elif string.isdigit(): + # Assume emoji id + emojistr = f"" + elif ':' in string: + # Assume custom emoji + emojistr = provided.strip() + elif string.isascii(): + # Probably not an emoji + raise UserInputError( + t(_p( + 'settype:emoji|error:parse', + "Could not parse `{provided}` as a Discord emoji. " + "Supported formats are builtin emojis (e.g. `{builtin}`), " + "custom emojis (e.g. {custom}), " + "or custom emoji ids (e.g. `{custom_id}`)." + )).format( + provided=provided, + builtin="🤔", + custom="*`<`*`CuteLeo:942499177135480942`*`>`*", + custom_id="942499177135480942", + ) + ) + else: + # We don't have a good way of testing for emoji unicode + # So just assume anything with unicode is an emoji. + emojistr = string + + return emojistr + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + """ + Optionally (see `_quote`) wrap the data string in backticks. + """ + if data: + return data + else: + return None + + @property + def as_partial(self) -> Optional[discord.PartialEmoji]: + return self._parse_emoji(self.data) + + @staticmethod + def _parse_emoji(emojistr: str): + """ + Converts a provided string into a PartialEmoji. + Deos not validate the emoji string. + """ + if not emojistr: + return None + elif ":" in emojistr: + emojistr = emojistr.strip('<>') + splits = emojistr.split(":") + if len(splits) == 3: + animated, name, id = splits + animated = bool(animated) + return discord.PartialEmoji(name=name, animated=animated, id=int(id)) + else: + return discord.PartialEmoji(name=emojistr) + + CT = TypeVar('CT', 'GuildChannel', 'discord.Object', 'discord.Thread') MCT = TypeVar('MCT', discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.Object) @@ -558,7 +668,7 @@ class IntegerSetting(InteractiveSetting[ParentID, int, int]): return f"`{data}`" -class EmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]): +class PartialEmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]): """ Setting type mixin describing an Emoji string. @@ -1140,15 +1250,6 @@ class DurationSetting(InteractiveSetting[ParentID, int, int]): return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days)) -class MessageSetting(StringSetting): - """ - Typed Setting ABC representing a message sent to Discord. - - Placeholder implemented as a StringSetting until Context is built. - """ - ... - - class ListSetting: """ Mixin to implement a setting type representing a list of existing settings. diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index 01876c84..c54d3cec 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -150,7 +150,7 @@ class TextTrackerCog(LionCog): ) # Batch-fetch lguilds - lguilds = await self.bot.core.lions.fetch_guilds(*(session.guildid for session in batch)) + lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch}) # Build data rows = [] diff --git a/src/utils/lib.py b/src/utils/lib.py index d258337e..e495019f 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -6,6 +6,7 @@ import re from contextvars import Context import discord +from discord.partial_emoji import _EmojiTag from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage from discord.ui import View @@ -770,3 +771,20 @@ def replace_multiple(format_string, mapping): pattern = '|'.join(f"({key})" for key in keys) string = re.sub(pattern, lambda match: str(mapping[keys[match.lastindex - 1]]), format_string) return string + + +def emojikey(emoji: discord.Emoji | discord.PartialEmoji | str): + """ + Produces a distinguishing key for an Emoji or PartialEmoji. + + Equality checks using this key should act as expected. + """ + if isinstance(emoji, _EmojiTag): + if emoji.id: + key = str(emoji.id) + else: + key = str(emoji.name) + else: + key = str(emoji) + + return key diff --git a/src/utils/monitor.py b/src/utils/monitor.py index 21a0a901..9c3613a7 100644 --- a/src/utils/monitor.py +++ b/src/utils/monitor.py @@ -47,7 +47,7 @@ class TaskMonitor(Generic[Taskid]): Similar to `schedule_tasks`, but wipe and reset the tasklist. """ self._taskmap = {tid: time for tid, time in tasks} - self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * tid * self._taskmap[tid]) + self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid])) self._wakeup.set() def schedule_tasks(self, *tasks: tuple[Taskid, int]) -> None: @@ -59,7 +59,7 @@ class TaskMonitor(Generic[Taskid]): we build an entirely new list, and always wake up the loop. """ self._taskmap |= {tid: time for tid, time in tasks} - self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid]) + self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid])) self._wakeup.set() def schedule_task(self, taskid: Taskid, timestamp: int) -> None: diff --git a/src/utils/ui/leo.py b/src/utils/ui/leo.py index 2379112d..6d77e13d 100644 --- a/src/utils/ui/leo.py +++ b/src/utils/ui/leo.py @@ -248,6 +248,13 @@ class MessageUI(LeoUI): # Refresh lock, to avoid cache collisions on refresh self._refresh_lock = asyncio.Lock() + @property + def channel(self): + if self._original is not None: + return self._original.channel + else: + return self._message.channel + # ----- UI API ----- async def run(self, interaction: discord.Interaction, **kwargs): """ @@ -366,6 +373,15 @@ class MessageUI(LeoUI): args = await self.make_message() self._message = await channel.send(**args.send_args, view=self) + async def _redraw(self, args): + 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 redraw(self, thinking: Optional[discord.Interaction] = None): """ Update the output message for this UI. @@ -379,13 +395,7 @@ class MessageUI(LeoUI): 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 already closed. Quietly cleanup. - await self.close() + await self._redraw(args) except discord.HTTPException: # Unknown communication erorr, nothing we can reliably do. Exit quietly. await self.close() diff --git a/src/utils/ui/micros.py b/src/utils/ui/micros.py index e02e5d24..eebf418f 100644 --- a/src/utils/ui/micros.py +++ b/src/utils/ui/micros.py @@ -167,7 +167,10 @@ class ModalRetryUI(LeoUI): async def respond_to(self, interaction): self._interaction = interaction - await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self) + if interaction.response.is_done(): + await interaction.followup.send(embed=self.embed, ephemeral=True, view=self) + else: + await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self) @button(label="Retry") async def retry_button(self, interaction, butt):