rewrite: New Rolemenu system. (Incomplete)
This commit is contained in:
@@ -13,6 +13,7 @@ active = [
|
||||
'.statistics',
|
||||
'.pomodoro',
|
||||
'.rooms',
|
||||
'.rolemenus',
|
||||
'.meta',
|
||||
'.test',
|
||||
]
|
||||
|
||||
10
src/modules/rolemenus/__init__.py
Normal file
10
src/modules/rolemenus/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
from babel.translator import LocalBabel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
babel = LocalBabel('rolemenus')
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
from .cog import RoleMenuCog
|
||||
await bot.add_cog(RoleMenuCog(bot))
|
||||
373
src/modules/rolemenus/cog.py
Normal file
373
src/modules/rolemenus/cog.py
Normal file
@@ -0,0 +1,373 @@
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
from discord import app_commands as appcmds
|
||||
from discord.app_commands import Range
|
||||
from discord.app_commands.transformers import AppCommandOptionType as cmdopt
|
||||
|
||||
from meta import LionCog, LionBot, LionContext
|
||||
from meta.logger import log_wrap
|
||||
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled
|
||||
from meta.sharding import THIS_SHARD
|
||||
from utils.lib import utc_now, error_embed
|
||||
from utils.ui import Confirm, ChoicedEnum, Transformed
|
||||
from constants import MAX_COINS
|
||||
|
||||
from wards import low_management_ward
|
||||
|
||||
from . import babel, logger
|
||||
from .data import RoleMenuData, MenuType
|
||||
from .rolemenu import RoleMenu, RoleMenuRole
|
||||
from .ui.menueditor import MenuEditor
|
||||
from .templates import templates
|
||||
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class MenuStyleParam(ChoicedEnum):
|
||||
REACTION = (
|
||||
_p('argtype:menu_style|opt:reaction', "Reaction Roles"),
|
||||
MenuType.REACTION
|
||||
)
|
||||
BUTTON = (
|
||||
_p('argtype:menu_style|opt:button', "Button Menu"),
|
||||
MenuType.BUTTON
|
||||
)
|
||||
DROPDOWN = (
|
||||
_p('argtype:menu_style|opt:dropdown', "Dropdown Menu"),
|
||||
MenuType.DROPDOWN
|
||||
)
|
||||
|
||||
@property
|
||||
def choice_name(self):
|
||||
return self.value[0]
|
||||
|
||||
@property
|
||||
def choice_value(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def data(self) -> MenuType:
|
||||
return self.value[1]
|
||||
|
||||
|
||||
class RoleMenuCog(LionCog):
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data = bot.db.load_registry(RoleMenuData())
|
||||
|
||||
# Menu caches
|
||||
self.guild_menus = defaultdict(dict) # guildid -> menuid -> RoleMenu
|
||||
self.guild_menu_messages = defaultdict(dict) # guildid -> messageid -> RoleMenu
|
||||
|
||||
# ----- Initialisation -----
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
|
||||
if self.bot.is_ready():
|
||||
await self.initialise()
|
||||
|
||||
async def cog_unload(self):
|
||||
...
|
||||
|
||||
@LionCog.listener('on_ready')
|
||||
@log_wrap(action="Initialise Role Menus")
|
||||
async def initialise(self):
|
||||
...
|
||||
|
||||
# ----- Cog API -----
|
||||
async def register_menus(*menus):
|
||||
...
|
||||
|
||||
async def deregister_menus(*menus):
|
||||
...
|
||||
|
||||
# ----- Private Utils -----
|
||||
async def _parse_msg(self, guild: discord.Guild, msgstr: str) -> discord.Message:
|
||||
"""
|
||||
Parse a message reference link into a Message.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
|
||||
error = None
|
||||
message = None
|
||||
splits = msgstr.strip().rsplit('/', maxsplit=2)
|
||||
if len(splits) == 2 and splits[0].isdigit() and splits[1].isdigit():
|
||||
chid, mid = map(int, splits)
|
||||
channel = guild.get_channel(chid)
|
||||
if channel is not None:
|
||||
try:
|
||||
message = await channel.fetch_message(mid)
|
||||
except discord.NotFound:
|
||||
error = t(_p(
|
||||
'parse:message_link|suberror:message_dne',
|
||||
"Could not find the linked message, has it been deleted?"
|
||||
))
|
||||
except discord.Forbidden:
|
||||
error = t(_p(
|
||||
'parse:message_link|suberror:no_perms',
|
||||
"Insufficient permissions! I need the `MESSAGE_HISTORY` permission in {channel}."
|
||||
)).format(channel=channel.menion)
|
||||
else:
|
||||
error = t(_p(
|
||||
'parse:message_link|suberror:channel_dne',
|
||||
"The channel `{channelid}` could not be found in this server."
|
||||
)).format(channelid=chid)
|
||||
else:
|
||||
error = t(_p(
|
||||
'parse:message_link|suberror:malformed_link',
|
||||
"Malformed message link. Please copy the link by right clicking the target message."
|
||||
))
|
||||
|
||||
if message is None:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'parse:message_link|error',
|
||||
"Failed to resolve the provided message link.\n**ERROR:** {error}"
|
||||
)).format(error=error)
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
async def _parse_menu(self, menustr: str, create=False) -> RoleMenu:
|
||||
...
|
||||
|
||||
async def _acmpl_menu(self, interaction: discord.Interaction, partial: str, allow_new=False):
|
||||
...
|
||||
|
||||
async def _parse_role(self, menu, rolestr) -> RoleMenuRole:
|
||||
"""
|
||||
Parse a provided role menu role.
|
||||
This can be given as 'rid-<id>', role mention, or role id.
|
||||
"""
|
||||
...
|
||||
|
||||
async def _acmpl_role(self, interaction: discord.Interaction, partial: str):
|
||||
...
|
||||
|
||||
# ----- Event Handlers -----
|
||||
# Message delete handler
|
||||
# Role delete handler
|
||||
# Reaction handler
|
||||
# Guild leave handler (stop listening)
|
||||
# Guild join handler (start listening)
|
||||
|
||||
# ----- Context Menu -----
|
||||
|
||||
# ----- Commands -----
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name=_p('cmd:rolemenus', "rolemenus"),
|
||||
description=_p(
|
||||
'cmd:rolemenus|desc',
|
||||
"View and configure the role menus in this server."
|
||||
)
|
||||
)
|
||||
async def rolemenus_cmd(self, ctx: LionContext):
|
||||
# Spawn the menus UI
|
||||
# Maybe accept a channel here to restrict the menus
|
||||
...
|
||||
|
||||
@cmds.hybrid_group(
|
||||
name=_p('group:rolemenu', "rolemenu"),
|
||||
description=_p(
|
||||
'group:rolemenu|desc',
|
||||
"Base command group for role menu configuration."
|
||||
)
|
||||
)
|
||||
@appcmds.guild_only()
|
||||
async def rolemenu_group(self, ctx: LionBot):
|
||||
...
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_create', "newmenu"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_create|desc',
|
||||
"Create a new role menu (optionally using an existing message)"
|
||||
)
|
||||
)
|
||||
@appcmds.choices(
|
||||
template=[
|
||||
template.as_choice() for template in templates.values()
|
||||
]
|
||||
)
|
||||
async def rolemenu_create_cmd(self, ctx: LionContext,
|
||||
name: appcmds.Range[str, 1, 64],
|
||||
message: Optional[str] = None,
|
||||
menu_style: Optional[Transformed[MenuStyleParam, cmdopt.string]] = None,
|
||||
required_role: Optional[discord.Role] = None,
|
||||
sticky: Optional[bool] = None,
|
||||
refunds: Optional[bool] = None,
|
||||
obtainable: Optional[appcmds.Range[int, 1, 25]] = None,
|
||||
template: Optional[appcmds.Choice[int]] = None,
|
||||
):
|
||||
# Type checking guards
|
||||
if ctx.guild is None:
|
||||
return
|
||||
if ctx.interaction is None:
|
||||
return
|
||||
|
||||
t = self.bot.translator.t
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
|
||||
# Parse provided target message if given
|
||||
if message is None:
|
||||
target_message = None
|
||||
target_mine = True
|
||||
else:
|
||||
# Parse provided message link into a Message
|
||||
target_message: discord.Message = await self._parse_msg(message)
|
||||
target_mine = (target_message.author == ctx.guild.me)
|
||||
|
||||
# Check that this message is not already attached to a role menu
|
||||
if target_message.id in (menu.data.messageid for menu in self.guild_menus[ctx.guild.id].values()):
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:rolemenu_create|error:message_exists',
|
||||
"The message {link} already has a role menu! Use {edit_cmd} to edit it."
|
||||
)).format(
|
||||
link=target_message.jump_url,
|
||||
edit_cmd=self.bot.core.mention_cache['rolemenu edit']
|
||||
)
|
||||
)
|
||||
|
||||
# Default menu type is Button if we own the message, reaction otherwise
|
||||
if menu_style is not None:
|
||||
menu_type = menu_style.data
|
||||
elif target_mine:
|
||||
menu_type = MenuType.BUTTON
|
||||
else:
|
||||
menu_type = MenuType.REACTION
|
||||
|
||||
# Handle incompatible options from unowned target message
|
||||
if not target_mine:
|
||||
if menu_type is not MenuType.REACTION:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:rolemenu_create|error:incompatible_style',
|
||||
"I cannot create a `{style}` style menu on a message I didn't send (Discord restriction)."
|
||||
)).format(style=t(menu_style.value[0]))
|
||||
)
|
||||
|
||||
# Parse menu options if given
|
||||
name = name.strip()
|
||||
if name.lower() in (menu.data.name.lower() for menu in self.guild_menus[ctx.guild.id].values()):
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'cmd:rolemenu_create|error:name_exists',
|
||||
"A rolemenu called `{name}` already exists! Use {edit_cmd} to edit it."
|
||||
)).format(name=name, edit_cmd=self.bot.core.mention_cache['rolemenu edit'])
|
||||
)
|
||||
|
||||
templateid = template.value if template is not None else None
|
||||
if target_message:
|
||||
message_data = {}
|
||||
message_data['content'] = target_message.content
|
||||
if target_message.embeds:
|
||||
message_data['embed'] = target_message.embeds[0].to_dict()
|
||||
rawmessage = json.dumps(message_data)
|
||||
else:
|
||||
rawmessage = None
|
||||
if templateid is None:
|
||||
templateid = 0
|
||||
|
||||
# Create RoleMenu data, set options if given
|
||||
data = await self.data.RoleMenu.create(
|
||||
guildid=ctx.guild.id,
|
||||
channelid=target_message.channel.id if target_message else None,
|
||||
messageid=target_message.id if target_message else None,
|
||||
name=name,
|
||||
enabled=True,
|
||||
required_roleid=required_role.id if required_role else None,
|
||||
sticky=sticky,
|
||||
refunds=refunds,
|
||||
obtainable=obtainable,
|
||||
menutype=menu_type,
|
||||
templateid=templateid,
|
||||
rawmessage=rawmessage,
|
||||
)
|
||||
# Create RoleMenu
|
||||
menu = RoleMenu(self.bot, data, [])
|
||||
|
||||
# Open editor, with preview if not a reaction role message
|
||||
editor = MenuEditor(self.bot, menu, callerid=ctx.author.id)
|
||||
await editor.run(ctx.interaction)
|
||||
await editor.wait()
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_edit', "editmenu"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_edit|desc',
|
||||
"Edit an existing (or in-creation) role menu."
|
||||
)
|
||||
)
|
||||
async def rolemenu_edit_cmd(self, ctx: LionContext):
|
||||
# Parse target
|
||||
# Parse provided options
|
||||
# Set options if provided
|
||||
# Open editor with preview
|
||||
...
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_delete', "delmenu"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_delete|desc',
|
||||
"Delete a role menu."
|
||||
)
|
||||
)
|
||||
async def rolemenu_delete_cmd(self, ctx: LionContext):
|
||||
# Parse target
|
||||
# Delete target
|
||||
...
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_addrole', "addrole"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_addrole|desc',
|
||||
"Add a new role to a new or existing role menu."
|
||||
)
|
||||
)
|
||||
async def rolemenu_addrole_cmd(self, ctx: LionContext,
|
||||
role: discord.Role,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
# Parse target menu, may need to create here
|
||||
# Parse target role
|
||||
# Check author permissions
|
||||
# Parse role options
|
||||
# Create RoleMenuRole
|
||||
# Ack, with open editor button
|
||||
...
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_editrole', "editrole"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_editrole|desc',
|
||||
"Edit role options in a role menu (supports in-creation menus)"
|
||||
)
|
||||
)
|
||||
async def rolemenu_editrole_cmd(self, ctx: LionContext):
|
||||
# Parse target menu
|
||||
# Parse target role
|
||||
# Check author permissions
|
||||
# Parse role options
|
||||
# Either ack changes or open the RoleEditor
|
||||
...
|
||||
|
||||
@rolemenu_group.command(
|
||||
name=_p('cmd:rolemenu_delrole', "delrole"),
|
||||
description=_p(
|
||||
'cmd:rolemenu_delrole|desc',
|
||||
"Remove a role from a role menu."
|
||||
)
|
||||
)
|
||||
async def rolemenu_delrole_cmd(self, ctx: LionContext):
|
||||
# Parse target menu
|
||||
# Parse target role
|
||||
# Remove role
|
||||
...
|
||||
69
src/modules/rolemenus/data.py
Normal file
69
src/modules/rolemenus/data.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from enum import Enum
|
||||
|
||||
from data import Registry, RowModel, RegisterEnum, Column
|
||||
from data.columns import Integer, Timestamp, String, Bool
|
||||
|
||||
|
||||
class MenuType(Enum):
|
||||
REACTION = 'REACTION',
|
||||
BUTTON = 'BUTTON',
|
||||
DROPDOWN = 'DROPDOWN',
|
||||
|
||||
|
||||
class RoleMenuData(Registry):
|
||||
MenuType = RegisterEnum(MenuType, name='RoleMenuType')
|
||||
|
||||
class RoleMenu(RowModel):
|
||||
_tablename_ = 'role_menus'
|
||||
_cache_ = {}
|
||||
|
||||
menuid = Integer(primary=True)
|
||||
guildid = Integer()
|
||||
|
||||
channelid = Integer()
|
||||
messageid = Integer()
|
||||
|
||||
name = String()
|
||||
enabled = Bool()
|
||||
|
||||
required_roleid = Integer()
|
||||
sticky = Bool()
|
||||
refunds = Bool()
|
||||
obtainable = Integer()
|
||||
|
||||
menutype: Column[MenuType] = Column()
|
||||
templateid = Integer()
|
||||
rawmessage = String()
|
||||
|
||||
class RoleMenuRole(RowModel):
|
||||
_tablename_ = 'role_menu_roles'
|
||||
_cache_ = {}
|
||||
|
||||
menuroleid = Integer(primary=True)
|
||||
|
||||
menuid = Integer()
|
||||
roleid = Integer()
|
||||
|
||||
label = String()
|
||||
emoji = String()
|
||||
description = String()
|
||||
|
||||
price = Integer()
|
||||
duration = Integer()
|
||||
|
||||
rawreply = String()
|
||||
|
||||
class RoleMenuHistory(RowModel):
|
||||
_tablename_ = 'role_menu_history'
|
||||
_cache_ = None
|
||||
|
||||
equipid = Integer(primary=True)
|
||||
|
||||
menuid = Integer()
|
||||
roleid = Integer()
|
||||
userid = Integer()
|
||||
|
||||
obtained_at = Timestamp()
|
||||
transactionid = Integer()
|
||||
expires_at = Timestamp()
|
||||
expired_at = Timestamp()
|
||||
0
src/modules/rolemenus/lib.py
Normal file
0
src/modules/rolemenus/lib.py
Normal file
145
src/modules/rolemenus/menuoptions.py
Normal file
145
src/modules/rolemenus/menuoptions.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
|
||||
from meta.errors import UserInputError
|
||||
from babel.translator import ctx_translator
|
||||
from settings import ModelData
|
||||
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||
from settings.setting_types import (
|
||||
RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting
|
||||
)
|
||||
|
||||
from .data import RoleMenuData
|
||||
from . import babel
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
# TODO: Write some custom accepts fields
|
||||
# TODO: The *name* might be an important setting!
|
||||
|
||||
|
||||
class RoleMenuConfig(ModelConfig):
|
||||
settings = SettingDotDict()
|
||||
_model_settings = set()
|
||||
model = RoleMenuData.RoleMenu
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.get(RoleMenuOptions.Name.setting_id)
|
||||
|
||||
@property
|
||||
def required_role(self):
|
||||
return self.get(RoleMenuOptions.RequiredRole.setting_id)
|
||||
|
||||
@property
|
||||
def sticky(self):
|
||||
return self.get(RoleMenuOptions.Sticky.setting_id)
|
||||
|
||||
@property
|
||||
def refunds(self):
|
||||
return self.get(RoleMenuOptions.Refunds.setting_id)
|
||||
|
||||
@property
|
||||
def obtainable(self):
|
||||
return self.get(RoleMenuOptions.Obtainable.setting_id)
|
||||
|
||||
|
||||
class RoleMenuOptions(SettingGroup):
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Name(ModelData, StringSetting):
|
||||
setting_id = 'name'
|
||||
|
||||
_display_name = _p('menuset:name', "name")
|
||||
_desc = _p(
|
||||
'menuset:name|desc',
|
||||
"Brief name for this role menu."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menuset:name|long_desc',
|
||||
"The role menu name is displayed when selecting the menu in commands, "
|
||||
"and as the title of most default menu layouts."
|
||||
)
|
||||
_default = 'Untitled'
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.name.name
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Sticky(ModelData, BoolSetting):
|
||||
setting_id = 'sticky'
|
||||
|
||||
_display_name = _p('menuset:sticky', "sticky_roles")
|
||||
_desc = _p(
|
||||
'menuset:sticky|desc',
|
||||
"Whether the menu can be used to unequip roles."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menuset:sticky|long_desc',
|
||||
"When enabled, members will not be able to remove equipped roles by selecting them in this menu. "
|
||||
"Note that when disabled, "
|
||||
"members will be able to unequip the menu roles even if they were not obtained from the menu."
|
||||
)
|
||||
_default = False
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.sticky.name
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Refunds(ModelData, BoolSetting):
|
||||
setting_id = 'refunds'
|
||||
|
||||
_display_name = _p('menuset:refunds', "refunds")
|
||||
_desc = _p(
|
||||
'menuset:refunds|desc',
|
||||
"Whether removing a role will refund the purchase price for that role."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menuset:refunds|long_desc',
|
||||
"When enabled, members who *purchased a role through this role menu* will obtain a full refund "
|
||||
"when they remove the role through the menu.\n"
|
||||
"**Refunds will only be given for roles purchased through the same menu.**\n"
|
||||
"**The `sticky` option must be disabled to allow members to remove roles.**"
|
||||
)
|
||||
_default = True
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.refunds.name
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Obtainable(ModelData, IntegerSetting):
|
||||
setting_id = 'obtainable'
|
||||
|
||||
_display_name = _p('menuset:obtainable', "obtainable")
|
||||
_desc = _p(
|
||||
'menuset:obtainable|desc',
|
||||
"The maximum number of roles equippable from this menu."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menus:obtainable|long_desc',
|
||||
"Members will not be able to obtain more than this number of roles from this menu. "
|
||||
"The counts roles that were not obtained through the rolemenu system."
|
||||
)
|
||||
_default = None
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.obtainable.name
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class RequiredRole(ModelData, RoleSetting):
|
||||
setting_id = 'required_role'
|
||||
|
||||
_display_name = _p('menuset:required_role', "required_role")
|
||||
_desc = _p(
|
||||
'menuset:required_role|desc',
|
||||
"Initial role required to use this menu."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menuset:required_role|long_desc',
|
||||
"If set, only members who have the `required_role` will be able to obtain or remove roles using this menu."
|
||||
)
|
||||
_default = None
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.required_roleid.name
|
||||
42
src/modules/rolemenus/rolemenu.py
Normal file
42
src/modules/rolemenus/rolemenu.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from meta import LionBot
|
||||
|
||||
|
||||
from .data import RoleMenuData as Data
|
||||
from .menuoptions import RoleMenuConfig
|
||||
from .roleoptions import RoleMenuRoleConfig
|
||||
|
||||
|
||||
class RoleMenuRole:
|
||||
def __init__(self, bot: LionBot, data: Data.RoleMenuRole):
|
||||
self.bot = bot
|
||||
self.data = data
|
||||
self.config = RoleMenuRoleConfig(data.menuroleid, data)
|
||||
|
||||
|
||||
class RoleMenu:
|
||||
def __init__(self, bot: LionBot, data: Data.RoleMenu, roles):
|
||||
self.bot = bot
|
||||
self.data = data
|
||||
self.config = RoleMenuConfig(data.menuid, data)
|
||||
self.roles: list[RoleMenuRole] = roles
|
||||
|
||||
self._message = None
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return self._message
|
||||
|
||||
async def fetch_message(self):
|
||||
...
|
||||
|
||||
async def reload(self):
|
||||
await self.data.refresh()
|
||||
roledata = self.bot.get_cog('RoleMenuCog').data.RoleMenuRole
|
||||
role_rows = await roledata.fetch_where(menuid=self.data.menuid)
|
||||
self.roles = [RoleMenuRole(self.bot, row) for row in role_rows]
|
||||
|
||||
async def make_view(self):
|
||||
...
|
||||
|
||||
async def make_args(self):
|
||||
...
|
||||
149
src/modules/rolemenus/roleoptions.py
Normal file
149
src/modules/rolemenus/roleoptions.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from settings import ModelData
|
||||
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||
from settings.setting_types import (
|
||||
RoleSetting, BoolSetting, StringSetting, DurationSetting
|
||||
)
|
||||
from core.setting_types import CoinSetting
|
||||
|
||||
from .data import RoleMenuData
|
||||
from . import babel
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class RoleMenuRoleConfig(ModelConfig):
|
||||
settings = SettingDotDict()
|
||||
_model_settings = set()
|
||||
model = RoleMenuData.RoleMenuRole
|
||||
|
||||
@property
|
||||
def role(self):
|
||||
return self.get(RoleMenuRoleOptions.Role.setting_id)
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self.get(RoleMenuRoleOptions.Label.setting_id)
|
||||
|
||||
@property
|
||||
def emoji(self):
|
||||
return self.get(RoleMenuRoleOptions.Emoji.setting_id)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.get(RoleMenuRoleOptions.Description.setting_id)
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
return self.get(RoleMenuRoleOptions.Price.setting_id)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
return self.get(RoleMenuRoleOptions.Duration.setting_id)
|
||||
|
||||
|
||||
class RoleMenuRoleOptions(SettingGroup):
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Role(ModelData, RoleSetting):
|
||||
setting_id = 'role'
|
||||
|
||||
_display_name = _p('roleset:role', "role")
|
||||
_desc = _p(
|
||||
'roleset:role|desc',
|
||||
"The role associated to this menu item."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:role|long_desc',
|
||||
"The role given when this menu item is selected in the role menu."
|
||||
)
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.roleid.name
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Label(ModelData, StringSetting):
|
||||
setting_id = 'role'
|
||||
|
||||
_display_name = _p('roleset:label', "label")
|
||||
_desc = _p(
|
||||
'roleset:label|desc',
|
||||
"A short button label for this role."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:label|long_desc',
|
||||
"A short name for this role, to be displayed in button labels, dropdown titles, and some menu layouts. "
|
||||
"By default uses the Discord role name."
|
||||
)
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.label.name
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Emoji(ModelData, StringSetting):
|
||||
setting_id = 'emoji'
|
||||
|
||||
_display_name = _p('roleset:emoji', "emoji")
|
||||
_desc = _p(
|
||||
'roleset:emoji|desc',
|
||||
"The emoji associated with this role."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:emoji|long_desc',
|
||||
"The role emoji is used for the reaction (in reaction role menus), "
|
||||
"and otherwise appears next to the role label in the button and dropdown styles. "
|
||||
"The emoji is also displayed next to the role in most menu templates."
|
||||
)
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.emoji.name
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Description(ModelData, StringSetting):
|
||||
setting_id = 'description'
|
||||
|
||||
_display_name = _p('roleset:description', "description")
|
||||
_desc = _p(
|
||||
'roleset:description|desc',
|
||||
"A longer description of this role."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:description|long_desc',
|
||||
"The description is displayed under the role label in dropdown style menus. "
|
||||
"It may also be used as a substitution key in custom role selection responses."
|
||||
)
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.description.name
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Price(ModelData, CoinSetting):
|
||||
setting_id = 'price'
|
||||
|
||||
_display_name = _p('roleset:price', "price")
|
||||
_desc = _p(
|
||||
'roleset:price|desc',
|
||||
"Price of the role, in LionCoins."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:price|long_desc',
|
||||
"How much the role costs when selected, in LionCoins."
|
||||
)
|
||||
_default = 0
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.price.name
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Duration(ModelData, DurationSetting):
|
||||
setting_id = 'duration'
|
||||
|
||||
_display_name = _p('roleset:duration', "duration")
|
||||
_desc = _p(
|
||||
'roleset:duration|desc',
|
||||
"Lifetime of the role after selection"
|
||||
)
|
||||
_long_desc = _p(
|
||||
'roleset:duration|long_desc',
|
||||
"Allows creation of 'temporary roles' which expire a given time after being equipped. "
|
||||
"Refunds will not be given upon expiry."
|
||||
)
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.duration.name
|
||||
207
src/modules/rolemenus/templates.py
Normal file
207
src/modules/rolemenus/templates.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import discord
|
||||
from discord.ui.select import SelectOption
|
||||
from discord.app_commands import Choice
|
||||
|
||||
from utils.lib import MessageArgs
|
||||
from babel.translator import ctx_translator
|
||||
|
||||
from . import babel
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
DEFAULT_EMOJI = '🔲'
|
||||
|
||||
|
||||
templates = {}
|
||||
|
||||
|
||||
class Template:
|
||||
def __init__(self, id, name, description, formatter):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.formatter = formatter
|
||||
|
||||
def as_option(self) -> SelectOption:
|
||||
# Select options need to be strings, so we localise
|
||||
t = ctx_translator.get().t
|
||||
name = t(self.name)
|
||||
description = t(self.description)
|
||||
return SelectOption(label=name, value=str(self.id), description=description)
|
||||
|
||||
def as_choice(self) -> Choice[int]:
|
||||
# Appcmd choices are allowed to be LazyStrings, so we don't localise
|
||||
return Choice(name=self.name, value=self.id)
|
||||
|
||||
async def render_menu(self, menu) -> MessageArgs:
|
||||
# TODO: Some error catching and logging might be good here
|
||||
return await self.formatter(menu)
|
||||
|
||||
|
||||
def register_template(id, name, description):
|
||||
def wrapper(coro):
|
||||
template = Template(id, name, description, coro)
|
||||
templates[id] = template
|
||||
return template
|
||||
return wrapper
|
||||
|
||||
|
||||
@register_template(
|
||||
id=0,
|
||||
name=_p(
|
||||
'template:simple|name', "Simple Menu"
|
||||
),
|
||||
description=_p(
|
||||
'template:simple|desc',
|
||||
"A simple embedded list of roles in the menu"
|
||||
)
|
||||
)
|
||||
async def simple_template(menu) -> MessageArgs:
|
||||
menuroles = menu.roles
|
||||
lines = []
|
||||
for menurole in menuroles:
|
||||
parts = []
|
||||
emoji = menurole.config.emoji
|
||||
role = menurole.config.role
|
||||
price = menurole.config.price
|
||||
duration = menurole.config.duration
|
||||
|
||||
if emoji.data:
|
||||
parts.append(emoji.formatted)
|
||||
|
||||
parts.append(role.formatted)
|
||||
|
||||
if price.data:
|
||||
parts.append(f"({price.formatted})")
|
||||
|
||||
if duration.data:
|
||||
parts.append(f"({duration.formatted})")
|
||||
|
||||
lines.append(' '.join(parts))
|
||||
|
||||
description = '\n'.join(lines)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=menu.config.name.value,
|
||||
description=description,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
|
||||
@register_template(
|
||||
id=1,
|
||||
name=_p(
|
||||
'template:two_column|name', "Two Column"
|
||||
),
|
||||
description=_p(
|
||||
'template:two_column|desc',
|
||||
"A compact two column role layout. Excludes prices and durations."
|
||||
)
|
||||
)
|
||||
async def twocolumn_template(menu) -> MessageArgs:
|
||||
menuroles = menu.roles
|
||||
|
||||
count = len(menuroles)
|
||||
split_at = count // 2
|
||||
|
||||
blocks = (menuroles[:split_at], menuroles[split_at:])
|
||||
|
||||
embed = discord.Embed(
|
||||
title=menu.config.name.value,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
for block in blocks:
|
||||
block_lines = [
|
||||
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
|
||||
for menurole in block
|
||||
]
|
||||
if block_lines:
|
||||
embed.add_field(
|
||||
name='',
|
||||
value='\n'.join(block_lines)
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
|
||||
@register_template(
|
||||
id=2,
|
||||
name=_p(
|
||||
'template:three_column|name', "Three Column"
|
||||
),
|
||||
description=_p(
|
||||
'template:three_column|desc',
|
||||
"A compact three column layout using emojis and labels, excluding prices and durations."
|
||||
)
|
||||
)
|
||||
async def threecolumn_template(menu) -> MessageArgs:
|
||||
menuroles = menu.roles
|
||||
|
||||
count = len(menuroles)
|
||||
split_at = count // 3
|
||||
if count % 3 == 2:
|
||||
split_at += 1
|
||||
|
||||
blocks = (menuroles[:split_at], menuroles[split_at:2*split_at], menuroles[2*split_at:])
|
||||
|
||||
embed = discord.Embed(
|
||||
title=menu.config.name.value,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
for block in blocks:
|
||||
block_lines = [
|
||||
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
|
||||
for menurole in block
|
||||
]
|
||||
if block_lines:
|
||||
embed.add_field(
|
||||
name='',
|
||||
value='\n'.join(block_lines)
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
|
||||
@register_template(
|
||||
id=3,
|
||||
name=_p(
|
||||
'template:shop|name', "Role Shop"
|
||||
),
|
||||
description=_p(
|
||||
'template:shop|desc',
|
||||
"A single column display suitable for simple role shops"
|
||||
)
|
||||
)
|
||||
async def shop_template(menu) -> MessageArgs:
|
||||
menuroles = menu.roles
|
||||
width = max(len(str(menurole.config.price.data)) for menurole in menuroles)
|
||||
|
||||
lines = []
|
||||
for menurole in menuroles:
|
||||
parts = []
|
||||
emoji = menurole.config.emoji
|
||||
role = menurole.config.role
|
||||
price = menurole.config.price
|
||||
duration = menurole.config.duration
|
||||
|
||||
parts.append(f"`{price.value:>{width}} LC`")
|
||||
parts.append("|")
|
||||
|
||||
if emoji.data:
|
||||
parts.append(emoji.formatted)
|
||||
|
||||
parts.append(role.formatted)
|
||||
|
||||
if duration.data:
|
||||
parts.append(f"({duration.formatted})")
|
||||
|
||||
lines.append(' '.join(parts))
|
||||
|
||||
description = '\n'.join(lines)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=menu.config.name.value,
|
||||
description=description,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
0
src/modules/rolemenus/ui/menu.py
Normal file
0
src/modules/rolemenus/ui/menu.py
Normal file
745
src/modules/rolemenus/ui/menueditor.py
Normal file
745
src/modules/rolemenus/ui/menueditor.py
Normal file
@@ -0,0 +1,745 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
import discord
|
||||
from discord.ui.button import button, Button, ButtonStyle
|
||||
from discord.ui.select import select, Select, RoleSelect, ChannelSelect, SelectOption
|
||||
|
||||
from meta import LionBot, conf
|
||||
from meta.errors import UserInputError
|
||||
from utils.lib import utc_now, MessageArgs, error_embed
|
||||
from utils.ui import MessageUI, ConfigEditor, FastModal, error_handler_for, ModalRetryUI, MsgEditor
|
||||
from babel.translator import ctx_locale
|
||||
from wards import equippable_role
|
||||
|
||||
from .. import babel
|
||||
from ..data import MenuType, RoleMenuData
|
||||
from ..rolemenu import RoleMenu, RoleMenuRole
|
||||
from ..menuoptions import RoleMenuOptions
|
||||
from ..templates import templates
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
class RoleEditorInput(FastModal):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@error_handler_for(UserInputError)
|
||||
async def rerequest(self, interaction, error):
|
||||
await ModalRetryUI(self, error.msg).respond_to(interaction)
|
||||
|
||||
|
||||
class EditorMode(Enum):
|
||||
OPTIONS = 0
|
||||
ROLES = 1
|
||||
STYLE = 2
|
||||
|
||||
|
||||
class MenuEditor(MessageUI):
|
||||
def _init_children(self):
|
||||
# HACK to stop ViewWeights complaining that this UI has too many children
|
||||
# Children will be correctly initialised after parent init.
|
||||
return []
|
||||
|
||||
def __init__(self, bot: LionBot, menu: RoleMenu, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._children = super()._init_children()
|
||||
|
||||
self.bot = bot
|
||||
self.menu = menu
|
||||
self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data
|
||||
|
||||
# UI State
|
||||
self.mode: EditorMode = EditorMode.ROLES
|
||||
self.pagen: int = 0
|
||||
self._preview: Optional[discord.Interaction] = None
|
||||
|
||||
# ----- UI API -----
|
||||
async def dispatch_update(self):
|
||||
"""
|
||||
Broadcast that the menu has changed.
|
||||
|
||||
This updates the preview, and tells the menu itself to update any linked messages.
|
||||
"""
|
||||
await self.menu.reload()
|
||||
if self._preview is not None:
|
||||
args = await self._preview_args()
|
||||
try:
|
||||
await self._preview.edit_original_response(**args.edit_args)
|
||||
except discord.NotFound:
|
||||
self._preview = None
|
||||
|
||||
async def _preview_args(self):
|
||||
if (tid := self.menu.data.templateid) is not None:
|
||||
# Apply template
|
||||
template = templates[tid]
|
||||
args = await template.render_menu(self.menu)
|
||||
else:
|
||||
raw = self.menu.data.rawmessage
|
||||
data = json.loads(raw)
|
||||
args = MessageArgs(
|
||||
content=data.get('content', ''),
|
||||
embed=discord.Embed.from_dict(data['embed'])
|
||||
)
|
||||
return args
|
||||
|
||||
# ----- Components -----
|
||||
# -- Options Components --
|
||||
# Menu Options Button
|
||||
@button(label="OPTIONS_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||
async def options_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Change mode to 'Options'.
|
||||
"""
|
||||
await press.response.defer()
|
||||
self.mode = EditorMode.OPTIONS
|
||||
await self.refresh()
|
||||
|
||||
async def options_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.options_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:options|label',
|
||||
"Menu Options"
|
||||
))
|
||||
if self.mode is EditorMode.OPTIONS:
|
||||
button.style = ButtonStyle.blurple
|
||||
else:
|
||||
button.style = ButtonStyle.grey
|
||||
|
||||
# Bulk Edit Button
|
||||
@button(label="BULK_EDIT_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def bulk_edit_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Open a Config-like modal to textually edit the Menu options.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
instances = (
|
||||
self.menu.config.name,
|
||||
self.menu.config.sticky,
|
||||
self.menu.config.refunds,
|
||||
self.menu.config.obtainable,
|
||||
self.menu.config.required_role,
|
||||
)
|
||||
fields = [instance.input_field for instance in instances]
|
||||
fields = [field for field in fields if fields]
|
||||
originals = [field.value for field in fields]
|
||||
modal = ConfigEditor(
|
||||
*fields,
|
||||
title=t(_p(
|
||||
'ui:menu_editor|button:bulk_edit|modal|title',
|
||||
"Menu Options"
|
||||
))
|
||||
)
|
||||
|
||||
@modal.submit_callback()
|
||||
async def save_options(interaction: discord.Interaction):
|
||||
modified = []
|
||||
for instance, field, original in zip(instances, fields, originals):
|
||||
if field.value != original:
|
||||
# Option was modified, attempt to parse
|
||||
userstr = field.value.strip()
|
||||
if not userstr:
|
||||
new_data = None
|
||||
else:
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr)
|
||||
instance.data = new_data
|
||||
modified.append(instance)
|
||||
if modified:
|
||||
# All fields have been parsed, it is safe to respond
|
||||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||
# Write settings
|
||||
for instance in modified:
|
||||
await instance.write()
|
||||
# Propagate an update
|
||||
await self.dispatch_update()
|
||||
# Refresh the UI
|
||||
await self.refresh(thinking=interaction)
|
||||
else:
|
||||
# Nothing was modified, quietly accept
|
||||
await interaction.response.defer(thinking=False)
|
||||
|
||||
await press.response.send_modal(modal)
|
||||
|
||||
async def bulk_edit_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.bulk_edit_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:bulk_edit|label',
|
||||
"Bulk Edit"
|
||||
))
|
||||
|
||||
# Toggle Sticky Button
|
||||
@button(label="STICKY_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||
async def sticky_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Toggle the menu.config.sticky flag.
|
||||
"""
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
instance = self.menu.config.sticky
|
||||
instance.value = not instance.value
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def sticky_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.sticky_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:sticky|label',
|
||||
"Toggle Sticky"
|
||||
))
|
||||
if self.menu.config.sticky.value:
|
||||
button.style = ButtonStyle.blurple
|
||||
else:
|
||||
button.style = ButtonStyle.grey
|
||||
|
||||
# Toggle Refunds Button
|
||||
@button(label="REFUNDS_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||
async def refunds_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Toggle the menu.config.refunds flag.
|
||||
"""
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
instance = self.menu.config.refunds
|
||||
instance.value = not instance.value
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def refunds_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.refunds_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:refunds|label',
|
||||
"Refunds"
|
||||
))
|
||||
if self.menu.config.refunds.value:
|
||||
button.style = ButtonStyle.blurple
|
||||
else:
|
||||
button.style = ButtonStyle.grey
|
||||
|
||||
# Required Roles Menu
|
||||
@select(cls=RoleSelect, placeholder="REQROLES_MENU_PLACEHOLDER", min_values=0, max_values=1)
|
||||
async def reqroles_menu(self, selection: discord.Interaction, selected: RoleSelect):
|
||||
"""
|
||||
Set or reset the required role option for this menu.
|
||||
"""
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
|
||||
if selected.values:
|
||||
new_data = selected.values[0].id
|
||||
else:
|
||||
new_data = None
|
||||
|
||||
instance = self.menu.config.required_role
|
||||
instance.data = new_data
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
|
||||
async def reqroles_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.reqroles_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:reqroles|placeholder',
|
||||
"Select Required Role"
|
||||
))
|
||||
|
||||
# -- Roles Components --
|
||||
# Modify Roles Button
|
||||
@button(label="MODIFY_ROLES_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def modify_roles_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Change mode to 'Roles'.
|
||||
"""
|
||||
await press.response.defer()
|
||||
self.mode = EditorMode.ROLES
|
||||
await self.refresh()
|
||||
|
||||
async def modify_roles_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.modify_roles_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:modify_roles|label',
|
||||
"Modify Roles"
|
||||
))
|
||||
if self.mode is EditorMode.ROLES:
|
||||
button.style = ButtonStyle.blurple
|
||||
else:
|
||||
button.style = ButtonStyle.grey
|
||||
|
||||
async def _edit_menu_role(self, interaction: discord.Interaction, menurole: RoleMenuRole):
|
||||
"""
|
||||
Handle edit flow for the given RoleMenuRole.
|
||||
|
||||
Opens the modal editor, and upon submit, also opens the RoleEditor.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
config = menurole.config
|
||||
instances = (
|
||||
config.label,
|
||||
config.emoji,
|
||||
config.description,
|
||||
config.price,
|
||||
config.duration,
|
||||
)
|
||||
fields = [instance.input_field for instance in instances]
|
||||
fields = [field for field in fields if fields]
|
||||
originals = [field.value for field in fields]
|
||||
modal = ConfigEditor(
|
||||
*fields,
|
||||
title=t(_p(
|
||||
'ui:menu_editor|role_editor|modal|title',
|
||||
"Edit Menu Role"
|
||||
))
|
||||
)
|
||||
|
||||
@modal.submit_callback()
|
||||
async def save_options(interaction: discord.Interaction):
|
||||
modified = []
|
||||
for instance, field, original in zip(instances, fields, originals):
|
||||
if field.value != original:
|
||||
# Option was modified, attempt to parse
|
||||
userstr = field.value.strip()
|
||||
if not userstr:
|
||||
new_data = None
|
||||
else:
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr)
|
||||
instance.data = new_data
|
||||
modified.append(instance)
|
||||
if modified:
|
||||
# All fields have been parsed, it is safe to respond
|
||||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||
# Write settings
|
||||
for instance in modified:
|
||||
await instance.write()
|
||||
# Propagate an update
|
||||
await self.dispatch_update()
|
||||
# Refresh the UI
|
||||
await self.refresh(thinking=interaction)
|
||||
else:
|
||||
# Nothing was modified, quietly accept
|
||||
await interaction.response.defer(thinking=False)
|
||||
|
||||
await interaction.response.send_modal(modal)
|
||||
await self.dispatch_update()
|
||||
|
||||
# Add Roles Menu
|
||||
@select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25)
|
||||
async def add_roles_menu(self, selection: discord.Interaction, selected: RoleSelect):
|
||||
"""
|
||||
Add one or multiple roles to the menu.
|
||||
|
||||
Behaviour is slightly different between one or multiple roles.
|
||||
For one role, if it already exists then it is edited. If it doesn't exist
|
||||
then it is added and an editor opened for it.
|
||||
For multiple roles, they are ORed with the existing roles,
|
||||
and no prompt is given for the fields.
|
||||
"""
|
||||
roles = selected.values
|
||||
if len(roles) == 0:
|
||||
await selection.response.defer(thinking=False)
|
||||
else:
|
||||
# Check equipment validity and permissions
|
||||
for role in roles:
|
||||
await equippable_role(self.bot, role, selection.user)
|
||||
|
||||
single = None
|
||||
to_create = {role.id: role for role in roles}
|
||||
for mrole in self.menu.roles:
|
||||
if to_create.pop(mrole.data.roleid, None) is not None:
|
||||
single = mrole
|
||||
|
||||
if to_create:
|
||||
t = self.bot.translator.t
|
||||
# Check numbers
|
||||
if self.menu.data.menutype is MenuType.REACTION and len(self.menu.roles) + len(to_create) > 20:
|
||||
raise UserInputError(t(_p(
|
||||
'ui:menu_editor|menu:add_roles|error:too_many_reactions',
|
||||
"Too many roles! Reaction role menus cannot exceed `20` roles."
|
||||
)))
|
||||
if len(self.menu.roles) + len(to_create) > 25:
|
||||
raise UserInputError(t(_p(
|
||||
'ui:menu_editor|menu:add_roles|error:too_many_roles',
|
||||
"Too many roles! Role menus cannot have more than `25` roles."
|
||||
)))
|
||||
|
||||
# Create roles
|
||||
# TODO: Emoji generation
|
||||
rows = await self.data.RoleMenuRole.table.insert_many(
|
||||
('menuid', 'roleid', 'label'),
|
||||
*((self.menu.data.menuid, role.id, role.name[:100]) for role in to_create.values())
|
||||
).with_adapter(self.data.RoleMenuRole._make_rows)
|
||||
mroles = [RoleMenuRole(self.bot, row) for row in rows]
|
||||
single = single if single is not None else mroles[0]
|
||||
await self.dispatch_update()
|
||||
|
||||
if len(roles) == 1:
|
||||
await self._edit_menu_role(selection, single)
|
||||
await self.refresh()
|
||||
else:
|
||||
await selection.response.defer()
|
||||
await self.refresh()
|
||||
|
||||
async def add_roles_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.add_roles_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:add_roles|placeholder',
|
||||
"Add Roles"
|
||||
))
|
||||
|
||||
def _role_option(self, menurole: RoleMenuRole):
|
||||
return SelectOption(
|
||||
label=menurole.config.label.value,
|
||||
value=str(menurole.data.menuroleid),
|
||||
description=menurole.config.description.value,
|
||||
)
|
||||
|
||||
# Edit Roles Menu
|
||||
@select(cls=Select, placeholder="EDIT_ROLES_MENU_PLACEHOLDER", min_values=1, max_values=1)
|
||||
async def edit_roles_menu(self, selection: discord.Interaction, selected: Select):
|
||||
"""
|
||||
Edit a single selected role.
|
||||
"""
|
||||
menuroleid = int(selected.values[0])
|
||||
menurole = next(menurole for menurole in self.menu.roles if menurole.data.menuroleid == menuroleid)
|
||||
await self._edit_menu_role(selection, menurole)
|
||||
|
||||
async def edit_roles_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.edit_roles_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:edit_roles|placeholder',
|
||||
"Edit Roles"
|
||||
))
|
||||
options = [self._role_option(menurole) for menurole in self.menu.roles]
|
||||
if options:
|
||||
menu.options = options
|
||||
menu.disabled = False
|
||||
else:
|
||||
menu.options = [SelectOption(label='DUMMY')]
|
||||
menu.disabled = True
|
||||
|
||||
# Delete Roles Menu
|
||||
@select(cls=Select, placeholder="DEL_ROLE_MENU_PLACEHOLDER", min_values=0, max_values=25)
|
||||
async def del_role_menu(self, selection: discord.Interaction, selected: Select):
|
||||
"""
|
||||
Remove one or multiple menu roles.
|
||||
"""
|
||||
menuroleids = list(map(int, selected.values))
|
||||
if menuroleids:
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids)
|
||||
await self.dispatch_update()
|
||||
else:
|
||||
await selection.response.defer(thinking=False)
|
||||
|
||||
async def del_role_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.del_role_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:del_role|placeholder',
|
||||
"Remove Roles"
|
||||
))
|
||||
options = [self._role_option(menurole) for menurole in self.menu.roles]
|
||||
if options:
|
||||
menu.options = options
|
||||
menu.disabled = False
|
||||
else:
|
||||
menu.options = [SelectOption(label='DUMMY')]
|
||||
menu.disabled = True
|
||||
menu.max_values = len(menu.options)
|
||||
|
||||
# -- Style Components --
|
||||
# Menu Style Button
|
||||
@button(label="STYLE_BUTTON_PLACEHOLDER", style=ButtonStyle.grey)
|
||||
async def style_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Change mode to 'Style'.
|
||||
"""
|
||||
if self.menu.message and self.menu.message.author != self.menu.message.guild.me:
|
||||
t = self.bot.translator.t
|
||||
# Non-managed message, cannot change style
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:menu_editor|button:style|error:non-managed',
|
||||
"Cannot change the style of a menu attached to a message I did not send! Please RePost first."
|
||||
))
|
||||
)
|
||||
|
||||
await press.response.defer()
|
||||
self.mode = EditorMode.STYLE
|
||||
await self.refresh()
|
||||
|
||||
async def style_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.style_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:style|label',
|
||||
"Menu Style"
|
||||
))
|
||||
if self.mode is EditorMode.STYLE:
|
||||
button.style = ButtonStyle.blurple
|
||||
else:
|
||||
button.style = ButtonStyle.grey
|
||||
|
||||
# Style Menu
|
||||
@select(cls=Select, placeholder="STYLE_MENU_PLACEHOLDER", min_values=1, max_values=1)
|
||||
async def style_menu(self, selection: discord.Interaction, selected: Select):
|
||||
"""
|
||||
Select one of Reaction Roles / Dropdown / Button
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
value = int(selected.values[0])
|
||||
menutype = MenuType(value)
|
||||
if menutype is not self.menu.data.menutype:
|
||||
# A change is requested
|
||||
if menutype is MenuType.REACTION:
|
||||
# Some checks need to be done when moving to reaction roles
|
||||
menuroles = self.menu.roles
|
||||
if len(menuroles) > 20:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:menu_editor|menu:style|error:too_many_reactions',
|
||||
"Too many roles! The Reaction style is limited to `20` roles (Discord limitation)."
|
||||
))
|
||||
)
|
||||
emojis = [mrole.config.emoji.value for mrole in menuroles]
|
||||
emojis = [emoji for emoji in emojis if emoji]
|
||||
uniq_emojis = set(emojis)
|
||||
if len(uniq_emojis) != len(menuroles):
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:menu_editor|menu:style|error:incomplete_emojis',
|
||||
"Cannot switch to the Reaction Role Style! Every role needs to have a distinct emoji first."
|
||||
))
|
||||
)
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
await self.menu.data.update(menutype=menutype)
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
else:
|
||||
await selection.response.defer()
|
||||
|
||||
async def style_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.style_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:style|placeholder',
|
||||
"Select Menu Style"
|
||||
))
|
||||
menu.options = [
|
||||
SelectOption(
|
||||
label=t(_p('ui:menu_editor|menu:style|option:reaction|label', "Reaction Roles")),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|menu:style|option:reaction|desc',
|
||||
"Roles are represented compactly as clickable reactions on a message."
|
||||
)),
|
||||
value=str(MenuType.REACTION.value),
|
||||
default=(self.menu.data.menutype is MenuType.REACTION)
|
||||
),
|
||||
SelectOption(
|
||||
label=t(_p('ui:menu_editor|menu:style|option:button|label', "Button Menu")),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|menu:style|option:button|desc',
|
||||
"Roles are represented in 5 rows of 5 buttons, each with an emoji and label."
|
||||
)),
|
||||
value=str(MenuType.BUTTON.value),
|
||||
default=(self.menu.data.menutype is MenuType.BUTTON)
|
||||
),
|
||||
SelectOption(
|
||||
label=t(_p('ui:menu_editor|menu:style|option:dropdown|label', "Dropdown Menu")),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|menu:style|option:dropdown|desc',
|
||||
"Roles are selectable from a dropdown menu below the message."
|
||||
)),
|
||||
value=str(MenuType.DROPDOWN.value),
|
||||
default=(self.menu.data.menutype is MenuType.DROPDOWN)
|
||||
)
|
||||
]
|
||||
|
||||
async def _editor_callback(self, new_data):
|
||||
raws = json.dumps(new_data)
|
||||
await self.menu.data.update(rawmessage=raws)
|
||||
await self.dispatch_update()
|
||||
|
||||
async def _message_editor(self, interaction: discord.Interaction):
|
||||
# Spawn the message editor with the current rawmessage data.
|
||||
editor = MsgEditor(
|
||||
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
|
||||
)
|
||||
self._slaves.append(editor)
|
||||
await editor.run(interaction)
|
||||
|
||||
# Template/Custom Menu
|
||||
@select(cls=Select, placeholder="TEMPLATE_MENU_PLACEHOLDER", min_values=1, max_values=1)
|
||||
async def template_menu(self, selection: discord.Interaction, selected: Select):
|
||||
"""
|
||||
Select a template for the menu message, or create a custom message.
|
||||
|
||||
If the custom message does not already exist, it will be based on the current template.
|
||||
"""
|
||||
templateid = int(selected.values[0])
|
||||
if templateid != self.menu.data.templateid:
|
||||
# Changes requested
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
if templateid == -1:
|
||||
# Chosen a custom message
|
||||
# Initialise the custom message if needed
|
||||
update_args = {'templateid': None}
|
||||
if not self.menu.data.rawmessage:
|
||||
template = templates[self.menu.data.templateid]
|
||||
margs = await template.render_menu(self.menu)
|
||||
raw = {
|
||||
'content': margs.kwargs.get('content', ''),
|
||||
}
|
||||
if 'embed' in margs.kwargs:
|
||||
raw['embed'] = margs.kwargs['embed'].to_dict()
|
||||
rawjson = json.dumps(raw)
|
||||
update_args['rawmessage'] = rawjson
|
||||
|
||||
# Save choice to data
|
||||
await self.menu.data.update(**update_args)
|
||||
|
||||
# Spawn editor
|
||||
await self._message_editor(selection)
|
||||
await self.dispatch_update()
|
||||
await self.refresh()
|
||||
else:
|
||||
await self.menu.data.update(templateid=templateid)
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
else:
|
||||
await selection.response.defer()
|
||||
|
||||
async def template_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
menu = self.template_menu
|
||||
menu.placeholder = t(_p(
|
||||
'ui:menu_editor|menu:template|placeholder',
|
||||
"Select Message Template"
|
||||
))
|
||||
options = []
|
||||
for template in templates.values():
|
||||
option = template.as_option()
|
||||
option.default = (self.menu.data.templateid == template.id)
|
||||
options.append(option)
|
||||
custom_option = SelectOption(
|
||||
label=t(_p(
|
||||
'ui:menu_editor|menu:template|option:custom|label',
|
||||
"Custom Message"
|
||||
)),
|
||||
value='-1',
|
||||
description=t(_p(
|
||||
'ui:menu_editor|menu:template|option:custom|description',
|
||||
"Entirely custom menu message (opens an interactive editor)."
|
||||
)),
|
||||
default=(self.menu.data.templateid is None)
|
||||
)
|
||||
options.append(custom_option)
|
||||
menu.options = options
|
||||
|
||||
# -- Common Components --
|
||||
# Delete Menu Button
|
||||
# Quit Button
|
||||
|
||||
# Page left Button
|
||||
# Edit Message Button
|
||||
# Preview Button
|
||||
@button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def preview_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Display or update the preview message.
|
||||
"""
|
||||
args = await self._preview_args()
|
||||
if self._preview is not None:
|
||||
try:
|
||||
await self._preview.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
self._preview = None
|
||||
await press.response.send_message(**args.send_args, ephemeral=True)
|
||||
self._preview = press
|
||||
|
||||
async def preview_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.preview_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:preview|label',
|
||||
"Preview"
|
||||
))
|
||||
|
||||
# Repost Menu Button
|
||||
|
||||
# ----- UI Flow -----
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
|
||||
title = t(_p(
|
||||
'ui:menu_editor|embed|title',
|
||||
"'{name}' Role Menu Editor"
|
||||
)).format(name=self.menu.config.name.value)
|
||||
|
||||
table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid)
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=title,
|
||||
description=table
|
||||
)
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def refresh_layout(self):
|
||||
to_refresh = (
|
||||
self.options_button_refresh(),
|
||||
self.reqroles_menu_refresh(),
|
||||
self.sticky_button_refresh(),
|
||||
self.refunds_button_refresh(),
|
||||
self.bulk_edit_button_refresh(),
|
||||
self.modify_roles_button_refresh(),
|
||||
self.add_roles_menu_refresh(),
|
||||
self.edit_roles_menu_refresh(),
|
||||
self.del_role_menu_refresh(),
|
||||
self.style_button_refresh(),
|
||||
self.style_menu_refresh(),
|
||||
self.template_menu_refresh(),
|
||||
self.preview_button_refresh(),
|
||||
)
|
||||
await asyncio.gather(*to_refresh)
|
||||
|
||||
line_1 = (
|
||||
self.options_button, self.modify_roles_button, self.style_button,
|
||||
)
|
||||
line_last = (
|
||||
self.preview_button,
|
||||
)
|
||||
if self.mode is EditorMode.OPTIONS:
|
||||
self.set_layout(
|
||||
line_1,
|
||||
(self.bulk_edit_button, self.sticky_button, self.refunds_button,),
|
||||
(self.reqroles_menu,),
|
||||
line_last,
|
||||
)
|
||||
elif self.mode is EditorMode.ROLES:
|
||||
self.set_layout(
|
||||
line_1,
|
||||
(self.add_roles_menu,),
|
||||
(self.edit_roles_menu,),
|
||||
(self.del_role_menu,),
|
||||
line_last
|
||||
)
|
||||
elif self.mode is EditorMode.STYLE:
|
||||
self.set_layout(
|
||||
line_1,
|
||||
(self.style_menu,),
|
||||
(self.template_menu,),
|
||||
line_last
|
||||
)
|
||||
|
||||
async def reload(self):
|
||||
...
|
||||
0
src/modules/rolemenus/ui/menus.py
Normal file
0
src/modules/rolemenus/ui/menus.py
Normal file
0
src/modules/rolemenus/ui/msgeditor.py
Normal file
0
src/modules/rolemenus/ui/msgeditor.py
Normal file
0
src/modules/rolemenus/ui/roleeditor.py
Normal file
0
src/modules/rolemenus/ui/roleeditor.py
Normal file
Reference in New Issue
Block a user