rewrite: New Rolemenu system. (Incomplete)

This commit is contained in:
2023-07-31 21:06:51 +03:00
parent d4800f1db6
commit f0c796ce31
21 changed files with 3013 additions and 1 deletions

View File

@@ -13,6 +13,7 @@ active = [
'.statistics',
'.pomodoro',
'.rooms',
'.rolemenus',
'.meta',
'.test',
]

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

View 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
...

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

View File

View 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

View 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):
...

View 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

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

View File

View 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):
...

View File

View File

View File

97
src/utils/ansi.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Minimal library for making Discord Ansi colour codes.
"""
from enum import StrEnum
PREFIX = u'\u001b'
class TextColour(StrEnum):
Gray = '30'
Red = '31'
Green = '32'
Yellow = '33'
Blue = '34'
Pink = '35'
Cyan = '36'
White = '37'
def __str__(self) -> str:
return AnsiColour(fg=self).as_str()
def __call__(self):
return AnsiColour(fg=self)
class BgColour(StrEnum):
FireflyDarkBlue = '40'
Orange = '41'
MarbleBlue = '42'
GrayTurq = '43'
Gray = '44'
Indigo = '45'
LightGray = '46'
White = '47'
def __str__(self) -> str:
return AnsiColour(bg=self).as_str()
def __call__(self):
return AnsiColour(bg=self)
class Format(StrEnum):
NORMAL = '0'
BOLD = '1'
UNDERLINE = '4'
NOOP = '9'
def __str__(self) -> str:
return AnsiColour(self).as_str()
def __call__(self):
return AnsiColour(self)
class AnsiColour:
def __init__(self, *flags, fg=None, bg=None):
self.text_colour = fg
self.background_colour = bg
self.reset = (Format.NORMAL in flags)
self._flags = set(flags)
self._flags.discard(Format.NORMAL)
@property
def flags(self):
return (*((Format.NORMAL,) if self.reset else ()), *self._flags)
def as_str(self):
parts = []
if self.reset:
parts.append(Format.NORMAL)
elif not self.flags:
parts.append(Format.NOOP)
parts.extend(self._flags)
for c in (self.text_colour, self.background_colour):
if c is not None:
parts.append(c)
partstr = ';'.join(part.value for part in parts)
return f"{PREFIX}[{partstr}m" # ]
def __str__(self):
return self.as_str()
def __add__(self, obj: 'AnsiColour'):
text_colour = obj.text_colour or self.text_colour
background_colour = obj.background_colour or self.background_colour
flags = (*self.flags, *obj.flags)
return AnsiColour(*flags, fg=text_colour, bg=background_colour)
RESET = AnsiColour(Format.NORMAL)
BOLD = AnsiColour(Format.BOLD)
UNDERLINE = AnsiColour(Format.UNDERLINE)

View File

@@ -10,6 +10,7 @@ from .micros import *
from .pagers import *
from .transformed import *
from .config import *
from .msgeditor import *
# def create_task_in(coro, context: Context):

View File

@@ -8,9 +8,10 @@ import discord
from discord.ui import Modal, View, Item
from meta.logger import log_action_stack, logging_context
from meta.errors import SafeCancellation
from . import logger
from ..lib import MessageArgs
from ..lib import MessageArgs, error_embed
__all__ = (
'LeoUI',
@@ -198,6 +199,25 @@ class LeoUI(View):
"""
try:
raise error
except SafeCancellation as e:
if e.msg and not interaction.is_expired():
try:
if interaction.response.is_done():
await interaction.followup.send(
embed=error_embed(e.msg),
ephemeral=True
)
else:
await interaction.response.send_message(
embed=error_embed(e.msg),
ephemeral=True
)
except discord.HTTPException:
pass
logger.debug(
f"Caught a safe cancellation from LeoUI: {e.details}",
extra={'action': 'Cancel'}
)
except Exception:
logger.exception(
f"Unhandled interaction exception occurred in item {item!r} of LeoUI {self!r}",

View File

@@ -160,6 +160,7 @@ class ModalRetryUI(LeoUI):
@property
def embed(self):
return discord.Embed(
title="Uh-Oh!",
description=self.message,
colour=discord.Colour.red()
)

1022
src/utils/ui/msgeditor.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import discord.ext.commands as cmds
from babel.translator import LocalBabel
from meta import conf, LionContext, LionBot
from meta.errors import UserInputError
babel = LocalBabel('wards')
_p = babel._p
@@ -96,3 +97,79 @@ async def low_management_ward(ctx: LionContext) -> bool:
"You must have the `MANAGE_GUILD` permission in this server to do this!"
))
)
# ---- Assorted manual wards and checks ----
async def equippable_role(bot: LionBot, target_role: discord.Role, actor: discord.Member):
"""
Validator for an 'actor' setting a given 'target_role' as obtainable.
Checks that the 'target_role' is able to be given out,
that I am able to give it out, and that the 'actor' is able to give it out.
Raises UserInputError if any of these do not hold.
"""
t = bot.translator.t
guild = target_role.guild
me = guild.me
if target_role.is_bot_managed():
raise UserInputError(
t(_p(
'ward:equippable_role|error:bot_managed',
"I cannot manage {role} because it is managed by another bot!"
)).format(role=target_role.mention)
)
elif target_role.is_integration():
raise UserInputError(
t(_p(
'ward:equippable_role|error:integration',
"I cannot manage {role} because it is managed by a server integration."
)).format(role=target_role.mention)
)
elif target_role == guild.default_role:
raise UserInputError(
t(_p(
'ward:equippable_role|error:default_role',
"I cannot manage the server's default role."
)).format(role=target_role.mention)
)
elif not me.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'ward:equippable_role|error:no_perms',
"I need the `MANAGE_ROLES` permission before I can manage roles!"
)).format(role=target_role.mention)
)
elif me.top_role <= target_role:
raise UserInputError(
t(_p(
'ward:equippable_role|error:my_top_role',
"I cannot assign or remove {role} because it is above my top role!"
)).format(role=target_role.mention)
)
elif not target_role.is_assignable():
raise UserInputError(
t(_p(
'ward:equippable_role|error:not_assignable',
"I don't have sufficient permissions to assign or remove {role}."
)).format(role=target_role.mention)
)
if not actor.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'ward:equippable_role|error:actor_perms',
"You need the `MANAGE_ROLES` permission before you can configure roles!"
)).format(role=target_role.mention)
)
elif actor.top_role <= target_role and not actor == guild.owner:
raise UserInputError(
t(_p(
'ward:equippable_role|error:actor_top_role',
"You cannot configure {role} because it is above your top role!"
)).format(role=target_role.mention)
)
return True