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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 [[]]