rewrite: Complete rolemenu system.

This commit is contained in:
2023-08-10 14:12:50 +03:00
parent f0c796ce31
commit 021f57dc3a
19 changed files with 3605 additions and 184 deletions

View File

@@ -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()