Files
croccybot/src/modules/rolemenus/cog.py

1893 lines
72 KiB
Python

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.ui.button import ButtonStyle
from discord.app_commands import Range, Transform
from discord.app_commands.transformers import AppCommandOptionType as cmdopt
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation
from meta.sharding import THIS_SHARD
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now, error_embed, jumpto
from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents
from utils.transformers import DurationTransformer
from utils.monitor import TaskMonitor
from babel.translator import ctx_locale
from constants import MAX_COINS
from data import NULL
from wards import low_management_ward, equippable_role
from . import babel, logger
from .data import RoleMenuData, MenuType
from .rolemenu import RoleMenu, RoleMenuRole
from .ui.menueditor import MenuEditor
from .ui.menus import MenuList
from .templates import templates
from .menuoptions import RoleMenuOptions as RMOptions
from .roleoptions import RoleMenuRoleOptions as RMROptions
_p = babel._p
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]
# ----- Context Menu -----
@appcmds.context_menu(
name=_p('ctxcmd:rolemenu', "Role Menu Editor")
)
@appcmds.guild_only
async def rolemenu_ctxcmd(interaction: discord.Interaction, message: discord.Message):
bot: LionBot = interaction.client
self: RoleMenuCog = bot.get_cog('RoleMenuCog')
t = bot.translator.t
# Ward for manage_roles
if not interaction.user.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'ctxcmd:rolemenu|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to manage the server role menus."
))
)
if not interaction.guild.me.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'ctxcmd:rolemenus|error:my_perms',
"I lack the `MANAGE_ROLES` permission required to offer roles from role menus."
))
)
await interaction.response.defer(thinking=True, ephemeral=True)
# Lookup the rolemenu in the active message cache
menuid = self.live_menus[interaction.guild.id].get(message.id, None)
if menuid is None:
# Create a new menu
target_mine = (message.author == message.guild.me)
# Default menu type is Button if we own the message, reaction otherwise
if target_mine:
menu_type = MenuType.BUTTON
else:
menu_type = MenuType.REACTION
# TODO: Something to avoid duliplication
# Also localise
name = 'Untitled'
message_data = {}
message_data['content'] = message.content
if message.embeds:
message_data['embed'] = message.embeds[0].to_dict()
rawmessage = json.dumps(message_data)
# Create RoleMenu, set options if given
menu = await RoleMenu.create(
bot,
guildid=message.guild.id,
channelid=message.channel.id,
messageid=message.id,
name=name,
enabled=True,
menutype=menu_type,
rawmessage=rawmessage,
)
else:
menu = await RoleMenu.fetch(self.bot, menuid)
menu._message = message
await menu.update_raw()
# Open the editor
editor = MenuEditor(self.bot, menu, callerid=interaction.user.id)
await editor.run(interaction)
await editor.wait()
class ExpiryMonitor(TaskMonitor):
...
class RoleMenuCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(RoleMenuData())
self.monitor = ComponentMonitor('RoleMenus', self._monitor)
self.ready = asyncio.Event()
# Menu caches
self.live_menus = RoleMenu.attached_menus # guildid -> messageid -> menuid
# Expiry manage
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
async def _monitor(self):
state = (
"<"
"RoleMenus"
" ready={ready}"
" cached={cached}"
" views={views}"
" live={live}"
" expiry={expiry}"
">"
)
data = dict(
ready=self.ready.is_set(),
live=sum(len(gmenus) for gmenus in self.live_menus.values()),
expiry=repr(self.expiry_monitor),
cached=len(RoleMenu._menus),
views=len(RoleMenu.menu_views),
)
if not self.ready.is_set():
level = StatusLevel.STARTING
info = f"(STARTING) Not initialised. {state}"
elif not self.expiry_monitor._monitor_task:
level = StatusLevel.ERRORED
info = f"(ERRORED) Expiry monitor not running. {state}"
else:
level = StatusLevel.OKAY
info = f"(OK) RoleMenu loaded and listening. {state}"
return ComponentStatus(level, info, info, data)
# ----- Initialisation -----
async def cog_load(self):
self.bot.system_monitor.add_component(self.monitor)
await self.data.init()
self.bot.tree.add_command(rolemenu_ctxcmd, override=True)
if self.bot.is_ready():
await self.initialise()
async def cog_unload(self):
for menu in list(RoleMenu._menus.values()):
menu.detach()
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
@LionCog.listener('on_ready')
@log_wrap(action="Initialise Role Menus")
async def initialise(self):
self.ready.clear()
# Clean up live menu tasks
for menu in list(RoleMenu._menus.values()):
menu.detach()
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
# Start monitor
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
self.expiry_monitor.start()
# Load guilds
guildids = [guild.id for guild in self.bot.guilds]
if guildids:
await self._initialise_guilds(*guildids)
self.ready.set()
async def _initialise_guilds(self, *guildids):
"""
Initialise the RoleMenus in the given guilds,
and launch their expiry tasks if required.
"""
# Fetch menu data from the guilds
menu_rows = await self.data.RoleMenu.fetch_where(guildid=guildids)
if not menu_rows:
# Nothing to initialise
return
menuids = [row.menuid for row in menu_rows]
guildids = {row.guildid for row in menu_rows}
# Fetch menu roles from these menus
role_rows = await self.data.RoleMenuRole.fetch_where(menuid=menuids).order_by('menuroleid')
# Initialise MenuRoles and partition by menu
role_menu_roles = defaultdict(dict)
for row in role_rows:
mrole = RoleMenuRole(self.bot, row)
role_menu_roles[row.menuid][row.menuroleid] = mrole
# Bulk fetch the Lion Guilds
await self.bot.core.lions.fetch_guilds(*guildids)
# Initialise and attach RoleMenus
for menurow in menu_rows:
menu = RoleMenu(self.bot, menurow, role_menu_roles[menurow.menuid])
await menu.attach()
# Fetch all unexpired expiring menu roles from these menus
expiring = await self.data.RoleMenuHistory.fetch_expiring_where(menuid=menuids)
if expiring:
await self.schedule_expiring(*expiring)
# ----- Cog API -----
async def fetch_guild_menus(self, guildid):
"""
Retrieve guild menus for the given guildid.
Uses cache where possible.
"""
# TODO: For efficiency, cache the guild menus as well
# Current guild-key cache only caches the *active* guild menus, which is insufficent
# But we actually keep all guild menus in the RoleMenu cache anyway,
# so we just need to refine that cache a bit.
# For now, we can live with every acmpl hitting the database.
rows = await self.data.RoleMenu.fetch_where(guildid=guildid)
menuids = [row.menuid for row in rows]
menus = []
for menuid in menuids:
menus.append(await RoleMenu.fetch(self.bot, menuid))
return menus
async def schedule_expiring(self, *rows: RoleMenuData.RoleMenuHistory):
"""
Schedule expiry of given equip rows.
"""
tasks = [
(row.equipid, row.expires_at.timestamp()) for row in rows if row.expires_at
]
if tasks:
self.expiry_monitor.schedule_tasks(*tasks)
logger.debug(
f"Scheduled rolemenu expiry tasks: {tasks}"
)
async def cancel_expiring_tasks(self, *equipids):
"""
Cancel (task) expiry of given equipds, if they are scheduled.
"""
self.expiry_monitor.cancel_tasks(*equipids)
logger.debug(
f"Cancelled rolemenu expiry tasks: {equipids}"
)
async def _expire(self, equipid: int):
"""
Attempt to expire the given equipid.
The equipid may no longer be valid, or may be unexpirable.
If the bot is no longer in the server, ignores the expiry.
If the member is no longer in the server, removes the role from persisted roles, if applicable.
"""
logger.debug(f"Expiring RoleMenu equipped role {equipid}")
rows = await self.data.RoleMenuHistory.fetch_expiring_where(equipid=equipid)
if rows:
equip_row = rows[0]
menu = await self.data.RoleMenu.fetch(equip_row.menuid)
guild = self.bot.get_guild(menu.guildid)
if guild is not None:
log_errors = []
lguild = await self.bot.core.lions.fetch_guild(menu.guildid)
t = self.bot.translator.t
ctx_locale.set(lguild.locale)
role = guild.get_role(equip_row.roleid)
if role is not None:
lion = await self.bot.core.lions.fetch_member(guild.id, equip_row.userid)
await lion.remove_role(role)
if (member := lion.member):
if role in member.roles:
logger.error(f"Expired {equipid}, but the member still has the role!")
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:remove_failed',
"Removed the role, but the member still has the role!!"
)))
else:
logger.info(f"Expired {equipid}, and successfully removed the role from the member!")
else:
logger.info(
f"Expired {equipid} for non-existent member {equip_row.userid}. "
"Removed from persistent roles."
)
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:member_gone',
"Member could not be found.. role has been removed from saved roles."
)))
else:
logger.info(f"Could not expire {equipid} because the role was not found.")
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:no_role',
"Role {role} no longer exists."
)).format(role=f"`{equip_row.roleid}`"))
now = utc_now()
lguild.log_event(
title=t(_p(
'eventlog|event:rolemenu_role_expire|title',
"Equipped role has expired"
)),
description=t(_p(
'eventlog|event:rolemenu_role_expire|desc',
"{member}'s role {role} has now expired."
)).format(
member=f"<@{equip_row.userid}>",
role=f"<@&{equip_row.roleid}>",
),
fields={
t(_p(
'eventlog|event:rolemenu_role_expire|field:menu',
"Obtained From"
)): (
jumpto(
menu.guildid, menu.channelid, menu.messageid
) if menu and menu.messageid else f"**{menu.name}**",
True
),
t(_p(
'eventlog|event:rolemenu_role_expire|field:menu',
"Obtained At"
)): (
discord.utils.format_dt(equip_row.obtained_at),
True
),
t(_p(
'eventlog|event:rolemenu_role_expire|field:expiry',
"Expiry"
)): (
discord.utils.format_dt(equip_row.expires_at),
True
),
},
errors=log_errors
)
await equip_row.update(removed_at=now)
else:
logger.info(f"Could not expire {equipid} because the guild was not found.")
else:
# equipid is no longer valid or is not expiring
logger.info(f"RoleMenu equipped role {equipid} is no longer valid or is not expiring.")
pass
# ----- 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)[-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.mention)
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 -----
@LionCog.listener('on_raw_reaction_add')
@LionCog.listener('on_raw_reaction_remove')
async def on_reaction(self, payload: discord.RawReactionActionEvent):
"""
Check the message is an active message.
If so, fetch the associated menu and pass on the reaction event.
"""
if payload.member and payload.member.bot:
return
menuid = self.live_menus[payload.guild_id].get(payload.message_id, None)
if menuid is not None:
menu = await RoleMenu.fetch(self.bot, menuid)
if menu.data.menutype is MenuType.REACTION:
await menu.handle_reaction(payload)
# Message delete handler
@LionCog.listener('on_message_delete')
async def detach_menu(self, message: discord.Message):
"""
Detach any associated menu.
Set _message and messageid to None.
"""
if not message.guild:
return
menuid = self.live_menus[message.guild.id].get(message.id, None)
if menuid is not None:
menu = await RoleMenu.fetch(self.bot, menuid)
menu.detach()
menu._message = None
await menu.data.update(messageid=None)
logger.info(
f"RoleMenu <menuid:{menu.data.menuid}> attached message deleted."
)
# Role delete handler
@LionCog.listener('on_role_delete')
async def delete_menu_role(self, role: discord.Role):
"""
Delete any rolemenuroles associated with the role.
Set equip removed_at.
Cancel any associated expiry tasks.
"""
records = await self.data.RoleMenuRole.table.delete_where(roleid=role.id)
if records:
menuids = set(record['menuid'] for record in records)
for menuid in menuids:
menu = await RoleMenu.fetch(self.bot, menuid)
await menu.reload_roles()
await menu.update_message()
equip_records = await self.data.RoleMenuHistory.table.update_where(
(self.data.RoleMenuHistory.removed_at == NULL),
roleid=role.id
).set(removed_at=utc_now())
if equip_records:
equipids = [equip_records['equipid'] for record in equip_records]
await self.cancel_expiring_tasks(*equipids)
# Guild leave handler (stop listening)
@LionCog.listener('on_guild_leave')
async def unload_guild_menus(self, guild: discord.Guild):
"""
Detach any listening menus from this guild.
Cancel any expiry tasks.
"""
menu_data = await self.data.RoleMenu.fetch_where(guildid=guild.id)
if menu_data:
listening = list(self.live_menus[guild.id].values())
for menu in listening:
menu.detach()
menuids = [row.menuid for row in menu_data]
expiring = await self.data.RoleMenuHistory.fetch_expiring_where(menuid=menuids)
if expiring:
equipids = [row.equipid for row in expiring]
await self.cancel_expiring_tasks(*equipids)
# Guild join handler (start listening)
@LionCog.listener('on_guild_join')
async def load_guild_menus(self, guild: discord.Guild):
"""
Run initialise for this guild.
"""
await self._initialise_guilds(guild.id)
# ----- Commands -----
@cmds.hybrid_command(
name=_p('cmd:rolemenus', "rolemenus"),
description=_p(
'cmd:rolemenus|desc',
"View and configure the role menus in this server."
)
)
@appcmds.guild_only
@appcmds.default_permissions(manage_roles=True)
async def rolemenus_cmd(self, ctx: LionContext):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
# Ward for manage_roles
if not ctx.author.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenus|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to manage the server role menus."
))
)
if not ctx.guild.me.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenus|error:my_perms',
"I lack the `MANAGE_ROLES` permission required to offer roles from role menus."
))
)
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
menusui = MenuList(self.bot, ctx.guild, callerid=ctx.author.id)
await menusui.run(ctx.interaction)
await menusui.wait()
async def _menu_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]:
"""
Generate a list of Choices matching the given menu string.
Menus are matched by name.
"""
# TODO: Make this more efficient so we aren't hitting data for every acmpl
t = self.bot.translator.t
guildid = interaction.guild.id
guild_menus = await self.fetch_guild_menus(guildid)
choices = []
to_match = partial.strip().lower()
for menu in guild_menus:
if to_match in menu.data.name.lower():
choice_name = menu.data.name
choice_value = f"menuid:{menu.data.menuid}"
choices.append(
appcmds.Choice(name=choice_name[:100], value=choice_value)
)
if not choices:
# Offer 'no menus matching' choice instead, with current partial
choice_name = t(_p(
'acmpl:menus|choice:no_choices|name',
"No role menus matching '{partial}'"
)).format(partial=partial)
choice_value = partial
choice = appcmds.Choice(
name=choice_name[:100], value=choice_value
)
choices.append(choice)
return choices[:25]
async def _role_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]:
"""
Generate a list of Choices representing menu roles matching the given partial.
Roles are matched by label and role name. Role mentions are acceptable.
Matches will only be given if the menu parameter has already been entered.
"""
t = self.bot.translator.t
menu_key = t(_p(
'acmpl:menuroles|param:menu|keyname', "menu"
), locale=interaction.data.get('locale', 'en-US'))
menu_name = interaction.namespace[menu_key] if menu_key in interaction.namespace else None
if menu_name is None:
choice_name = t(_p(
'acmpl:menuroles|choice:no_menu|name',
"Please select a menu first"
))
choice_value = partial
choices = [appcmds.Choice(name=choice_name[:100], value=choice_value)]
else:
# Resolve the menu name
menu: RoleMenu
if menu_name.startswith('menuid:') and menu_name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(menu_name[7:])
menu = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
guild_menus = await self.fetch_guild_menus(interaction.guild.id)
to_match = menu_name.strip().lower()
menu = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if menu is None:
choice = appcmds.Choice(
name=t(_p(
'acmpl:menuroles|choice:invalid_menu|name',
"Menu '{name}' does not exist!"
)).format(name=menu_name)[:100],
value=partial
)
choices = [choice]
else:
# We have a menu and can match roles
to_match = partial.strip().lower()
choices = []
for mrole in menu.roles:
matching = (to_match in mrole.config.label.value.lower())
role = interaction.guild.get_role(mrole.data.roleid)
if not matching and role:
matching = matching or (to_match in role.name.lower())
matching = matching or (to_match in role.mention)
if matching:
if role and (mrole.data.label != role.name):
name = f"{mrole.data.label} (@{role.name})"
else:
name = mrole.data.label
choice = appcmds.Choice(
name=name[:100],
value=f"<@&{mrole.data.roleid}>"
)
choices.append(choice)
if not choices:
choice = appcmds.Choice(
name=t(_p(
'acmpl:menuroles|choice:no_matching|name',
"No roles in this menu matching '{partial}'"
)).format(partial=partial)[:100],
value=partial
)
return choices[:25]
@cmds.hybrid_group(
name=_p('group:rolemenu', "rolemenu"),
description=_p(
'group:rolemenu|desc',
"Base command group for role menu configuration."
)
)
@appcmds.guild_only()
@appcmds.default_permissions(manage_roles=True)
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()
],
)
@appcmds.rename(
name=RMOptions.Name._display_name,
sticky=RMOptions.Sticky._display_name,
refunds=RMOptions.Refunds._display_name,
obtainable=RMOptions.Obtainable._display_name,
required_role=RMOptions.RequiredRole._display_name,
message=_p('cmd:rolemenu_create|param:message', "message_link"),
menu_style=_p('cmd:rolemenu_create|param:menu_style', "menu_style"),
template=_p('cmd:rolemenu_create|param:remplate', "template"),
rawmessage=_p('cmd:rolemenu_create|param:rawmessage', "custom_message"),
)
@appcmds.describe(
name=RMOptions.Name._desc,
sticky=RMOptions.Sticky._desc,
refunds=RMOptions.Refunds._desc,
obtainable=RMOptions.Obtainable._desc,
required_role=RMOptions.RequiredRole._desc,
message=_p(
'cmd:rolemenu_create|param:message|desc',
"Link to an existing message to turn it into a (reaction) role menu"
),
menu_style=_p(
'cmd:rolemenu_create|param:menu_style',
"Selection style for this menu (using buttons, dropdowns, or reactions)"
),
template=_p(
'cmd:rolemenu_create|param:template',
"Template to use for the menu message body"
),
rawmessage=_p(
'cmd:rolemenu_create|param:rawmessage',
"Attach a custom menu message to use"
),
)
@appcmds.default_permissions(manage_roles=True)
async def rolemenu_create_cmd(self, ctx: LionContext,
name: appcmds.Range[str, 1, 64],
message: Optional[str] = None,
menu_style: Optional[Transformed[MenuStyleParam, cmdopt.string]] = None,
sticky: Optional[bool] = None,
refunds: Optional[bool] = None,
obtainable: Optional[appcmds.Range[int, 1, 25]] = None,
required_role: Optional[discord.Role] = None,
template: Optional[appcmds.Choice[int]] = None,
rawmessage: Optional[discord.Attachment] = None,
):
# Type checking guards
if ctx.guild is None:
return
if ctx.interaction is None:
return
t = self.bot.translator.t
await ctx.interaction.response.defer(thinking=True)
# Ward for manage_roles
if not ctx.author.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to create new role menus."
))
)
if not ctx.guild.me.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:my_perms',
"I lack the `MANAGE_ROLES` permission needed to offer roles from role menus."
))
)
# Parse provided target message if given
if message is None:
target_message = None
target_mine = True
else:
# Parse provided message link into a Message
target_message: discord.Message = await self._parse_msg(ctx.guild, message)
target_mine = (target_message.author == ctx.guild.me)
# Check that this message is not already attached to a role menu
matching = await self.data.RoleMenu.fetch_where(messageid=target_message.id)
if matching:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:message_exists',
"The message {link} already has a role menu! Use {edit_cmd} to edit it."
)).format(
link=target_message.jump_url,
edit_cmd=self.bot.core.mention_cache['rolemenu editmenu']
)
)
# 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:style_notmine',
"I cannot create a `{style}` style menu on a message I didn't send! (Discord restriction)."
)).format(style=t(menu_style.value[0]))
)
if rawmessage is not None:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:rawmessage_notmine',
"Cannot apply a custom menu message to {message} because I do not own this message!"
)).format(
message=target_message.jump_url
)
)
if template is not None:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:template_notmine',
"Cannot apply a menu message template to {message} because I do not own this message!"
)).format(
message=target_message.jump_url
)
)
# Parse menu options if given
name = name.strip()
matching = await self.data.RoleMenu.fetch_where(name=name, guildid=ctx.guild.id)
if matching:
raise UserInputError(
t(_p(
'cmd:rolemenu_create|error:name_exists',
"A rolemenu called `{name}` already exists! Use {edit_cmd} to edit it."
)).format(name=name, edit_cmd=self.bot.core.mention_cache['rolemenu editmenu'])
)
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()
rawmessagedata = json.dumps(message_data)
else:
if rawmessage is not None:
# Attempt to parse rawmessage
rawmessagecontent = await RMOptions.Message.download_attachment(rawmessage)
rawmessagedata = await RMOptions.Message._parse_string(0, rawmessagecontent)
else:
rawmessagedata = None
if templateid is None:
templateid = 0
# Create RoleMenu data, set options if given
menu = await RoleMenu.create(
self.bot,
guildid=ctx.guild.id,
channelid=target_message.channel.id if target_message else None,
messageid=target_message.id if target_message else None,
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=rawmessagedata,
)
# If the message already exists and we own it, we may need to update it
if target_message and target_mine:
await menu.update_message()
# Open editor, with preview if not a reaction role message
editor = MenuEditor(self.bot, menu, callerid=ctx.author.id)
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 role menu."
)
)
@appcmds.choices(
template=[
template.as_choice() for template in templates.values()
],
)
@appcmds.rename(
name=_p('cmd:rolemenu_edit|param:name', "name"),
new_name=_p('cmd:rolemenu_edit|param:new_name', "new_name"),
channel=_p('cmd:rolemenu_edit|param:channel', "new_channel"),
sticky=RMOptions.Sticky._display_name,
refunds=RMOptions.Refunds._display_name,
obtainable=RMOptions.Obtainable._display_name,
required_role=RMOptions.RequiredRole._display_name,
menu_style=_p('cmd:rolemenu_edit|param:menu_style', "menu_style"),
template=_p('cmd:rolemenu_edit|param:remplate', "template"),
rawmessage=_p('cmd:rolemenu_edit|param:rawmessage', "custom_message"),
)
@appcmds.describe(
name=_p(
'cmd:rolemenu_edit|param:name|desc',
"Name of the menu to edit"
),
channel=_p(
'cmd:rolemenu_edit|param:channel|desc',
"Server channel to move the menu to"
),
new_name=RMOptions.Name._desc,
sticky=RMOptions.Sticky._desc,
refunds=RMOptions.Refunds._desc,
obtainable=RMOptions.Obtainable._desc,
required_role=RMOptions.RequiredRole._desc,
menu_style=_p(
'cmd:rolemenu_edit|param:menu_style',
"Selection style for this menu (using buttons, dropdowns, or reactions)"
),
template=_p(
'cmd:rolemenu_edit|param:template',
"Template to use for the menu message body"
),
rawmessage=_p(
'cmd:rolemenu_edit|param:rawmessage',
"Attach a custom menu message to use"
),
)
async def rolemenu_edit_cmd(self, ctx: LionContext,
name: appcmds.Range[str, 1, 64],
new_name: Optional[appcmds.Range[str, 1, 64]] = None,
channel: Optional[discord.TextChannel | discord.VoiceChannel] = None,
menu_style: Optional[Transformed[MenuStyleParam, cmdopt.string]] = None,
sticky: Optional[bool] = None,
refunds: Optional[bool] = None,
obtainable: Optional[appcmds.Range[int, 1, 25]] = None,
required_role: Optional[discord.Role] = None,
template: Optional[appcmds.Choice[int]] = None,
rawmessage: Optional[discord.Attachment] = None,
):
# Type checking guards
if ctx.guild is None:
return
if ctx.interaction is None:
return
t = self.bot.translator.t
await ctx.interaction.response.defer(ephemeral=True, thinking=True)
# Wards for manage_roles
if not ctx.author.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to edit role menus."
))
)
if not ctx.guild.me.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|error:my_perms',
"I lack the `MANAGE_ROLES` permission needed to offer roles from role menus."
))
)
# Parse target menu from name
guild_menus = await self.fetch_guild_menus(ctx.guild.id)
target: RoleMenu
if name.startswith('menuid:') and name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(name[7:])
target = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
to_match = name.strip().lower()
target = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if target is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|error:menu_not_found',
"This server does not have a role menu called `{name}`!"
)).format(name=name)
)
await target.fetch_message()
await target.update_raw()
# Parse provided options
reposting = channel is not None
managed = target.managed
update_args = {}
ack_lines = []
error_lines = []
if new_name is not None:
# Check whether the name already exists
for menu in guild_menus:
if menu.data.name.lower() == new_name.lower() and menu.data.menuid != target.data.menuid:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:new_name|error:name_exists',
"A role menu with the name **{new_name}** already exists!"
)).format(new_name=new_name)
)
name_config = target.config.name
name_config.value = new_name
update_args[name_config._column] = name_config.data
ack_lines.append(name_config.update_message)
if sticky is not None:
sticky_config = target.config.sticky
sticky_config.value = sticky
update_args[sticky_config._column] = sticky_config.data
ack_lines.append(sticky_config.update_message)
if refunds is not None:
refunds_config = target.config.refunds
refunds_config.value = refunds
update_args[refunds_config._column] = refunds_config.data
ack_lines.append(refunds_config.update_message)
if obtainable is not None:
obtainable_config = target.config.obtainable
obtainable_config.value = obtainable
update_args[obtainable_config._column] = obtainable_config.data
ack_lines.append(obtainable_config.update_message)
if required_role is not None:
required_role_config = target.config.required_role
required_role_config.value = required_role
update_args[required_role_config._column] = required_role_config.data
ack_lines.append(required_role_config.update_message)
if template is not None:
if not managed and not reposting:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:template|error:not_managed',
"Cannot set a template message for a role menu attached to a message I did not send."
))
)
templateid = template.value
if templateid == -1:
templateid = None
update_args[self.data.RoleMenu.templateid.name] = templateid
if templateid is not None:
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:template|success:template',
"Now using the `{name}` menu message template."
)).format(name=t(templates[templateid].name))
)
else:
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:template|success:custom',
"Now using a custom menu message."
))
)
# TODO: Generate the custom message from the template if it doesn't exist
if menu_style is not None:
if not managed and not reposting:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:style|error:not_managed',
"Cannot change the style of a role menu attached to a message I did not send."
))
)
if menu_style is MenuType.REACTION:
# Check menu is suitable for moving to reactions
roles = target.roles
if len(roles) > 20:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:style|error:too_many_reactions',
"Too many roles! Reaction role menus can have at most `20` roles."
))
)
emojis = [mrole.config.emoji.value for mrole in roles]
emojis = [emoji for emoji in emojis if emoji]
uniq = set(emojis)
if len(uniq) != len(roles):
raise UserInputError(
t(_p(
"cmd:rolemenu_edit|parse:style|error:incomplete_emojis",
"Cannot switch to the reaction role style! Every role needs a distinct emoji first."
))
)
update_args[self.data.RoleMenu.menutype.name] = menu_style
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:style|success',
"Updated role menu style."
))
)
if rawmessage is not None:
msg_config = target.config.rawmessage
content = await msg_config.download_attachment(rawmessage)
data = await msg_config._parse_string(0, content)
update_args[msg_config._column] = data
if template is None:
update_args[self.data.RoleMenu.templateid.name] = None
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:custom_message|success',
"Custom menu message updated."
))
)
# Update the data, if applicable
if update_args:
await target.data.update(**update_args)
# If we are reposting, do the repost
if reposting:
try:
await target.repost_to(channel)
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|repost|success',
"The role menu is now available at {message}"
)).format(message=target.jump_link)
)
if target.data.menutype is MenuType.REACTION:
try:
await target.update_reactons()
except SafeCancellation as e:
error_lines.append(e.msg)
except discord.Forbidden:
error_lines.append(t(_p(
'cmd:rolemenu_edit|repost|error:forbidden',
"Cannot update channel! I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission in {channel}."
)).format(channel=channel.mention))
except discord.HTTPException as e:
error_lines.append(t(_p(
'cmd:rolemenu_edit|repost|error:unknown',
"An unknown error occurred trying to repost the menu to {channel}.\n"
"**Error:** `{exception}`"
)).format(channel=channel.mention, exception=e.text))
else:
await target.update_message()
if menu_style is not None:
try:
await target.update_reactons()
except SafeCancellation as e:
error_lines.append(e.msg)
# Ack the updates
if ack_lines or error_lines:
tick = self.bot.config.emojis.tick
cross = self.bot.config.emojis.cancel
await ctx.interaction.edit_original_response(
embed=discord.Embed(
colour=discord.Colour.brand_green() if ack_lines else discord.Colour.brand_red(),
description='\n'.join((
*(f"{tick} {line}" for line in ack_lines),
*(f"{cross} {line}" for line in error_lines),
))
)
)
# Trigger listening MenuEditor update
listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid)
if (listen_key) not in MenuEditor._listening or not (ack_lines or error_lines):
ui = MenuEditor(self.bot, target, callerid=ctx.author.id)
await ui.run(ctx.interaction)
await ui.wait()
else:
ui = MenuEditor._listening[listen_key]
await ui.refresh()
await ui.update_preview()
rolemenu_edit_cmd.autocomplete('name')(_menu_acmpl)
@rolemenu_group.command(
name=_p('cmd:rolemenu_delete', "delmenu"),
description=_p(
'cmd:rolemenu_delete|desc',
"Delete a role menu."
)
)
@appcmds.rename(
name=_p('cmd:rolemenu_delete|param:name', "menu")
)
@appcmds.describe(
name=_p(
'cmd:rolemenu_delete|param:name|desc',
"Name of the rolemenu to delete."
)
)
async def rolemenu_delete_cmd(self, ctx: LionContext, name: appcmds.Range[str, 1, 64]):
if ctx.guild is None:
return
if ctx.interaction is None:
return
t = self.bot.translator.t
if not ctx.author.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_delete|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to manage the server role menus."
))
)
# Parse target
guild_menus = await self.fetch_guild_menus(ctx.guild.id)
target: RoleMenu
if name.startswith('menuid:') and name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(name[7:])
target = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
to_match = name.strip().lower()
target = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if target is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_delete|error:menu_not_found',
"This server does not have a role menu called `{name}`!"
)).format(name=name)
)
await target.fetch_message()
# Confirm
confirm_msg = t(_p(
'cmd:rolemenu_delete|confirm|title',
"Are you sure you want to delete the role menu **{name}**? This is not reversible!"
)).format(name=target.data.name)
confirm = Confirm(confirm_msg, ctx.author.id)
confirm.confirm_button.label = t(_p(
'cmd:rolemenu_delete|confirm|button:yes',
"Yes, Delete Now"
))
confirm.confirm_button.style = ButtonStyle.red
confirm.cancel_button.label = t(_p(
'cmd:rolemenu_delete|confirm|button:no',
"No, Cancel"
))
confirm.cancel_button.style = ButtonStyle.green
try:
result = await confirm.ask(ctx.interaction, ephemeral=True)
except ResponseTimedOut:
result = False
if result:
old_name = target.data.name
# Delete them menu
await target.delete()
# Close any menueditors that are listening
listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid)
listening = MenuEditor._listening.get(listen_key, None)
if listening is not None:
await listening.quit()
# Ack deletion
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:rolemenu_delete|success|desc',
"Successfully deleted the menu **{name}**"
)).format(name=old_name)
)
await ctx.interaction.followup.send(embed=embed, ephemeral=False)
rolemenu_delete_cmd.autocomplete('name')(_menu_acmpl)
@rolemenu_group.command(
name=_p('cmd:rolemenu_addrole', "addrole"),
description=_p(
'cmd:rolemenu_addrole|desc',
"Add a new role to an existing role menu."
)
)
@appcmds.rename(
menu=_p(
'cmd:rolemenu_addrole|param:menu', "menu"
),
role=_p(
'cmd:rolemenu_addrole|param:role', "role"
),
label=RMROptions.Label._display_name,
emoji=RMROptions.Emoji._display_name,
description=RMROptions.Description._display_name,
price=RMROptions.Price._display_name,
duration=RMROptions.Duration._display_name,
)
@appcmds.describe(
menu=_p(
'cmd:rolemenu_addrole|param:menu|desc',
"Name of the menu to add a role to"
),
role=_p(
'cmd:rolemenu_addrole|param:role|desc',
"Role to add to the menu"
),
label=RMROptions.Label._desc,
emoji=RMROptions.Emoji._desc,
description=RMROptions.Description._desc,
price=RMROptions.Price._desc,
duration=_p(
'cmd:rolemenu_addrole|param:duration|desc',
"Lifetime of the role after selection in minutes."
),
)
async def rolemenu_addrole_cmd(self, ctx: LionContext,
menu: appcmds.Range[str, 1, 64],
role: discord.Role,
label: Optional[appcmds.Range[str, 1, 100]] = None,
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
description: Optional[appcmds.Range[str, 0, 100]] = None,
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
):
# Type checking guards
if not ctx.interaction:
return
if not ctx.guild:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
# Permission ward
# Will check if the author has permission to manage this role
# Will check that the bot has permission to manage this role
# Raises UserInputError on lack of permissions
await equippable_role(self.bot, role, ctx.author)
t = self.bot.translator.t
# Parse target menu
name = menu
guild_menus = await self.fetch_guild_menus(ctx.guild.id)
target: RoleMenu
if name.startswith('menuid:') and name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(name[7:])
target = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
to_match = name.strip().lower()
target = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if target is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_addrole|error:menu_not_found',
"This server does not have a role menu called `{name}`!"
)).format(name=name)
)
await target.fetch_message()
target_is_reaction = (target.data.menutype is MenuType.REACTION)
# Parse target role
existing = next(
(mrole for mrole in target.roles if mrole.data.roleid == role.id),
None
)
parent_id = existing.data.menuroleid if existing is not None else role.id
# Parse provided config
data_args = {}
ack_lines = []
if not existing:
# Creation args
data_args = {
'menuid': target.data.menuid,
'roleid': role.id,
}
# label
# Use role name if not existing and not given
if (label is None) and (not existing):
label = role.name[:100]
if label is not None:
setting_cls = RMROptions.Label
data = setting_cls._data_from_value(parent_id, label)
data_args[setting_cls._column] = data
if existing:
instance = setting_cls(existing.data.menuroleid, data)
ack_lines.append(instance.update_message)
# emoji
# Autogenerate emoji if not exists and not given
if (emoji is None) and (not existing):
emoji = next(target.unused_emojis(include_defaults=target_is_reaction), None)
if emoji is not None:
setting_cls = RMROptions.Emoji
data = await setting_cls._parse_string(parent_id, emoji, interaction=ctx.interaction)
data_args[setting_cls._column] = data
if existing:
instance = setting_cls(existing.data.menuroleid, data)
ack_lines.append(instance.update_message)
# description
if description is not None:
setting_cls = RMROptions.Description
data = setting_cls._data_from_value(parent_id, description or None)
data_args[setting_cls._column] = data
if existing:
instance = setting_cls(existing.data.menuroleid, data)
ack_lines.append(instance.update_message)
# price
if price is not None:
setting_cls = RMROptions.Price
data = setting_cls._data_from_value(parent_id, price or None)
data_args[setting_cls._column] = data
if existing:
instance = setting_cls(existing.data.menuroleid, data)
ack_lines.append(instance.update_message)
# duration
if duration is not None:
setting_cls = RMROptions.Duration
data = setting_cls._data_from_value(parent_id, duration or None)
data_args[setting_cls._column] = data
if existing:
instance = setting_cls(existing.data.menuroleid, data)
ack_lines.append(instance.update_message)
# Create or edit RoleMenuRole
if not existing:
# Do create
data = await self.data.RoleMenuRole.create(**data_args)
# Ack creation
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'cmd:rolemenu_addrole|success:create|title',
"Added Menu Role"
)),
description=t(_p(
'cmd:rolemenu_addrole|success:create|desc',
"Add the role {role} to the menu **{menu}**."
)).format(
role=role.mention,
menu=target.data.name
)
)
# Update target roles
await target.reload_roles()
elif data_args:
# Do edit
await existing.data.update(**data_args)
# Ack edit
tick = self.bot.config.emojis.tick
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'cmd:rolemenu_addrole|success:edit|title',
"Menu Role updated"
)),
description='\n'.join(
f"{tick} {line}" for line in ack_lines
)
)
else:
# addrole was called on an existing role, but no options were modified
embed = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p(
'cmd:rolemenu_addrole|error:role_exists',
"The role {role} is already selectable from the menu **{menu}**"
)).format(
role=role.mention, menu=target.data.name
)
)
listen_key = (ctx.channel.id, ctx.author.id, target.data.menuid)
listening = MenuEditor._listening.get(listen_key, None)
if data_args:
# Update target and any listening editors
await target.update_message()
if target_is_reaction:
try:
await target.update_reactons()
except SafeCancellation as e:
embed.add_field(
name=t(_p(
'cmd:rolemenu_addrole|success|error:reaction|name',
"Note"
)),
value=e.msg
)
if listening is not None:
await listening.refresh()
await listening.update_preview()
# Ack, with open editor button if there is no open editor already
@AButton(
label=t(_p(
'cmd:rolemenu_addrole|success|button:editor|label',
"Edit Menu"
)),
style=ButtonStyle.blurple
)
async def editor_button(press: discord.Interaction, pressed):
ui = MenuEditor(self.bot, target, callerid=press.user.id)
await ui.run(press)
await ctx.interaction.followup.send(
embed=embed,
ephemeral=True,
view=AsComponents(editor_button) if listening is None else discord.utils.MISSING
)
rolemenu_addrole_cmd.autocomplete('menu')(_menu_acmpl)
@rolemenu_group.command(
name=_p('cmd:rolemenu_editrole', "editrole"),
description=_p(
'cmd:rolemenu_editrole|desc',
"Edit role options in an existing role menu."
)
)
@appcmds.rename(
menu=_p(
'cmd:rolemenu_editrole|param:menu', "menu"
),
menu_role=_p(
'cmd:rolemenu_editrole|param:menu_role', "menu_role"
),
role=_p(
'cmd:rolemenu_editrole|param:role', "new_role"
),
label=RMROptions.Label._display_name,
emoji=RMROptions.Emoji._display_name,
description=RMROptions.Description._display_name,
price=RMROptions.Price._display_name,
duration=RMROptions.Duration._display_name,
)
@appcmds.describe(
menu=_p(
'cmd:rolemenu_editrole|param:menu|desc',
"Name of the menu to edit the role for"
),
menu_role=_p(
'cmd:rolemenu_editrole|param:menu_role|desc',
"Label, name, or mention of the menu role to edit."
),
role=_p(
'cmd:rolemenu_editrole|param:role|desc',
"New server role this menu role should give."
),
label=RMROptions.Label._desc,
emoji=RMROptions.Emoji._desc,
description=RMROptions.Description._desc,
price=RMROptions.Price._desc,
duration=_p(
'cmd:rolemenu_editrole|param:duration|desc',
"Lifetime of the role after selection in minutes."
),
)
async def rolemenu_editrole_cmd(self, ctx: LionContext,
menu: appcmds.Range[str, 1, 64],
menu_role: appcmds.Range[str, 1, 64],
role: Optional[discord.Role] = None,
label: Optional[appcmds.Range[str, 1, 100]] = None,
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
description: Optional[appcmds.Range[str, 0, 100]] = None,
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
):
# Type checking wards
if not ctx.interaction:
return
if not ctx.guild:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
t = self.bot.translator.t
# Parse target menu
name = menu
guild_menus = await self.fetch_guild_menus(ctx.guild.id)
target_menu: RoleMenu
if name.startswith('menuid:') and name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(name[7:])
target_menu = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
to_match = name.strip().lower()
target_menu = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if target_menu is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_editrole|error:menu_not_found',
"This server does not have a role menu called `{name}`!"
)).format(name=name)
)
await target_menu.fetch_message()
# Parse target role
menu_roles = target_menu.roles
target_role: RoleMenuRole
if (maybe_id := menu_role.strip('<&@> ')).isdigit():
# Assume given as role mention or id
# Note that acmpl choices also provide mention
roleid = int(maybe_id)
target_role = next(
(mrole for mrole in menu_roles if mrole.data.roleid == roleid),
None
)
else:
# Assume given as mrole label
to_match = menu_role.strip().lower()
target_role = next(
(mrole for mrole in menu_roles if mrole.config.label.value.lower() == to_match),
None
)
if target_role is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_editrole|error:role_not_found',
"The menu **{menu}** does not have the role **{name}**"
)).format(menu=target_menu.data.name, name=menu_role)
)
# Check bot and author permissions
if current_role := ctx.guild.get_role(target_role.data.roleid):
await equippable_role(self.bot, current_role, ctx.author)
if role is not None:
await equippable_role(self.bot, role, ctx.author)
# Parse role options
data_args = {}
ack_lines = []
# new role
if role is not None:
config = target_role.config.role
config.value = role
data_args[config._column] = config.data
ack_lines.append(config.update_message)
# label
if label is not None:
config = target_role.config.label
config.value = label
data_args[config._column] = config.data
ack_lines.append(config.update_message)
# emoji
if emoji is not None:
config = target_role.config.emoji
config.data = await config._parse_string(config.parent_id, emoji, interaction=ctx.interaction)
data_args[config._column] = config.data
ack_lines.append(config.update_message)
# description
if description is not None:
config = target_role.config.description
config.data = await config._parse_string(config.parent_id, description)
data_args[config._column] = config.data
ack_lines.append(config.update_message)
# price
if price is not None:
config = target_role.config.price
config.value = price or None
data_args[config._column] = config.data
ack_lines.append(config.update_message)
# duration
if duration is not None:
config = target_role.config.duration
config.data = duration or None
data_args[config._column] = config.data
ack_lines.append(config.update_message)
if data_args:
# Perform updates
await target_role.data.update(**data_args)
# Ack updates
tick = self.bot.config.emojis.tick
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'cmd:rolemenu_editrole|success|title',
"Role menu role updated"
)),
description='\n'.join(
f"{tick} {line}" for line in ack_lines
)
)
await target_menu.update_message()
if target_menu.data.menutype is MenuType.REACTION and emoji is not None:
try:
await target_menu.update_reactons()
except SafeCancellation as e:
embed.add_field(
name=t(_p(
'cmd:rolemenu_editrole|success|error:reaction|name',
"Warning!"
)),
value=e.msg
)
await ctx.interaction.followup.send(
embed=embed,
ephemeral=True
)
listen_key = (ctx.channel.id, ctx.author.id, target_menu.data.menuid)
listening = MenuEditor._listening.get(listen_key, None)
if (listening is None) or (not data_args):
ui = MenuEditor(self.bot, target_menu, callerid=ctx.author.id)
await ui.run(ctx.interaction)
await ui.wait()
else:
await listening.refresh()
await listening.update_preview()
rolemenu_editrole_cmd.autocomplete('menu')(_menu_acmpl)
rolemenu_editrole_cmd.autocomplete('menu_role')(_role_acmpl)
@rolemenu_group.command(
name=_p('cmd:rolemenu_delrole', "delrole"),
description=_p(
'cmd:rolemenu_delrole|desc',
"Remove a role from a role menu."
)
)
@appcmds.rename(
menu=_p('cmd:rolemenu_delrole|param:menu', "menu"),
menu_role=_p('cmd:rolemenu_delrole|param:menu_role', "menu_role")
)
@appcmds.describe(
menu=_p(
'cmd:rolemenu_delrole|param:menu|desc',
"Name of the menu to delete the role from."
),
menu_role=_p(
'cmd:rolemenu_delrole|param:menu_role|desc',
"Name, label, or mention of the role to delete."
)
)
async def rolemenu_delrole_cmd(self, ctx: LionContext,
menu: appcmds.Range[str, 1, 64],
menu_role: appcmds.Range[str, 1, 64]
):
# Typechecking guards
if ctx.guild is None:
return
if ctx.interaction is None:
return
t = self.bot.translator.t
if not ctx.author.guild_permissions.manage_roles:
raise UserInputError(
t(_p(
'cmd:rolemenu_delrole|error:author_perms',
"You need the `MANAGE_ROLES` permission in order to manage the server role menus."
))
)
# Parse target menu
name = menu
guild_menus = await self.fetch_guild_menus(ctx.guild.id)
target_menu: RoleMenu
if name.startswith('menuid:') and name[7:].isdigit():
# Assume autogenerated from acmpl of the form menuid:id
menuid = int(name[7:])
target_menu = await RoleMenu.fetch(self.bot, menuid)
else:
# Assume it should match a menu name (case-insensitive)
to_match = name.strip().lower()
target_menu = next(
(menu for menu in guild_menus if menu.data.name.lower() == to_match),
None
)
if target_menu is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_delrole|error:menu_not_found',
"This server does not have a role menu called `{name}`!"
)).format(name=name)
)
await target_menu.fetch_message()
# Parse target role
menu_roles = target_menu.roles
target_role: RoleMenuRole
if (maybe_id := menu_role.strip('<&@> ')).isdigit():
# Assume given as role mention or id
# Note that acmpl choices also provide mention
roleid = int(maybe_id)
target_role = next(
(mrole for mrole in menu_roles if mrole.data.roleid == roleid),
None
)
else:
# Assume given as mrole label
to_match = menu_role.strip().lower()
target_role = next(
(mrole for mrole in menu_roles if mrole.config.label.value.lower() == to_match),
None
)
if target_role is None:
raise UserInputError(
t(_p(
'cmd:rolemenu_delrole|error:role_not_found',
"The menu **{menu}** does not have the role **{name}**"
)).format(menu=target_menu.data.name, name=menu_role)
)
await ctx.interaction.response.defer(thinking=True)
# Remove role and update target menu
old_name = target_role.data.label
await target_role.data.delete()
await target_menu.reload_roles()
await target_menu.update_message()
# Ack deletion
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:rolemenu_delrole|success',
"The role **{name}** was successfully removed from the menu **{menu}**."
)).format(name=old_name, menu=target_menu.config.name.value)
)
await ctx.interaction.edit_original_response(embed=embed)
# Update listening editor if it exists
listen_key = (ctx.channel.id, ctx.author.id, target_menu.data.menuid)
listening = MenuEditor._listening.get(listen_key, None)
if listening is not None:
await listening.refresh()
await listening.update_preview()
rolemenu_delrole_cmd.autocomplete('menu')(_menu_acmpl)
rolemenu_delrole_cmd.autocomplete('menu_role')(_role_acmpl)