rewrite: Complete rolemenu system.

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

View File

@@ -928,6 +928,92 @@ INSERT INTO schedule_session_members (guildid, userid, slotid, booked_at, attend
-- Drop old schema
-- }}}
-- Role Menus {{{
CREATE TYPE RoleMenuType AS ENUM(
'REACTION',
'BUTTON',
'DROPDOWN'
);
CREATE TABLE role_menus(
menuid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
channelid BIGINT,
messageid BIGINT,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT True,
required_roleid BIGINT,
sticky BOOLEAN,
refunds BOOLEAN,
obtainable INTEGER,
menutype RoleMenuType NOT NULL,
templateid INTEGER,
rawmessage TEXT,
default_price INTEGER,
event_log BOOLEAN
);
CREATE INDEX role_menu_guildid ON role_menus (guildid);
CREATE TABLE role_menu_roles(
menuroleid SERIAL PRIMARY KEY,
menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE,
roleid BIGINT NOT NULL,
label TEXT NOT NULL,
emoji TEXT,
description TEXT,
price INTEGER,
duration INTEGER,
rawreply TEXT
);
CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid);
CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid);
CREATE TABLE role_menu_history(
equipid SERIAL PRIMARY KEY,
menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE,
roleid BIGINT NOT NULL,
userid BIGINT NOT NULL,
obtained_at TIMESTAMPTZ NOT NULL,
transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
expires_at TIMESTAMPTZ,
removed_at TIMESTAMPTZ
);
CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid);
CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid);
-- Migration
INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype)
SELECT
messageid, guildid, channelid, enabled,
required_role, NOT removable, maximum,
refunds, event_log, default_price, messageid :: TEXT,
'REACTION'
FROM reaction_role_messages;
INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration)
SELECT
role_menus.menuid, reactions.roleid, reactions.roleid::TEXT,
COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name),
reactions.price, reactions.timeout
FROM reaction_role_reactions reactions
LEFT JOIN role_menus
ON role_menus.messageid = reactions.messageid;
INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at)
SELECT
rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry
FROM reaction_role_expiring expiring
LEFT JOIN role_menu_roles rmr
ON rmr.roleid = expiring.roleid
WHERE rmr.menuid IS NOT NULL;
-- }}}
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
COMMIT;

View File

@@ -24,8 +24,12 @@ CREATE TABLE role_menus(
obtainable INTEGER,
menutype RoleMenuType NOT NULL,
templateid INTEGER,
rawmessage TEXT
rawmessage TEXT,
default_price INTEGER,
event_log BOOLEAN
);
CREATE INDEX role_menu_guildid ON role_menus (guildid);
CREATE TABLE role_menu_roles(
@@ -39,6 +43,8 @@ CREATE TABLE role_menu_roles(
duration INTEGER,
rawreply TEXT
);
CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid);
CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid);
CREATE TABLE role_menu_history(
@@ -49,5 +55,35 @@ CREATE TABLE role_menu_history(
obtained_at TIMESTAMPTZ NOT NULL,
transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
expires_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ
removed_at TIMESTAMPTZ
);
CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid);
CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid);
-- Migration
INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype)
SELECT
messageid, guildid, channelid, enabled,
required_role, NOT removable, maximum,
refunds, event_log, default_price, messageid :: TEXT,
'REACTION'
FROM reaction_role_messages;
INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration)
SELECT
role_menus.menuid, reactions.roleid, reactions.roleid::TEXT,
COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name),
reactions.price, reactions.timeout
FROM reaction_role_reactions reactions
LEFT JOIN role_menus
ON role_menus.messageid = reactions.messageid;
INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at)
SELECT
rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry
FROM reaction_role_expiring expiring
LEFT JOIN role_menu_roles rmr
ON rmr.roleid = expiring.roleid
WHERE rmr.menuid IS NOT NULL;

View File

@@ -6,6 +6,7 @@ import discord
from meta import LionBot
from utils.lib import Timezoned
from settings.groups import ModelConfig, SettingDotDict
from babel.translator import SOURCE_LOCALE
from .data import CoreData
from .lion_user import LionUser
@@ -63,6 +64,21 @@ class LionMember(Timezoned):
guild_timezone = self.lguild.config.timezone
return user_timezone.value if user_timezone._data is not None else guild_timezone.value
def private_locale(self, interaction=None) -> str:
"""
Appropriate locale to use in private communication with this member.
Does not take into account guild force_locale.
"""
user_locale = self.luser.config.get('user_locale').value
interaction_locale = interaction.locale.value if interaction else None
guild_locale = self.lguild.config.get('guild_locale').value
locale = user_locale or interaction_locale
locale = locale or guild_locale
locale = locale or SOURCE_LOCALE
return locale
async def touch_discord_model(self, member: discord.Member):
"""
Update saved Discord model attributes for this member.
@@ -82,3 +98,15 @@ class LionMember(Timezoned):
except discord.HTTPException:
pass
return member
async def remove_role(self, role: discord.Role):
member = await self.fetch_member()
if member is not None and role in member.roles:
try:
await member.remove_roles(role)
except discord.HTTPException:
# TODO: Logging, audit logging
pass
else:
# TODO: Persistent role removal
...

View File

@@ -1,7 +1,14 @@
"""
Additional abstract setting types useful for StudyLion settings.
"""
from settings.setting_types import IntegerSetting
from typing import Optional
import json
import traceback
import discord
from settings.base import ParentID
from settings.setting_types import IntegerSetting, StringSetting
from meta import conf
from meta.errors import UserInputError
from constants import MAX_COINS
@@ -62,3 +69,202 @@ class CoinSetting(IntegerSetting):
"{coin}**{amount}**"
)).format(coin=conf.emojis.coin, amount=data)
return formatted
class MessageSetting(StringSetting):
"""
Typed Setting ABC representing a message sent to Discord.
Data is a json-formatted string dict with at least one of the fields 'content', 'embed', 'embeds'
Value is the corresponding dictionary
"""
# TODO: Extend to support format keys
_accepts = _p(
'settype:message|accepts',
"JSON formatted raw message data"
)
@staticmethod
async def download_attachment(attached: discord.Attachment):
"""
Download a discord.Attachment with some basic filetype and file size validation.
"""
t = ctx_translator.get().t
error = None
decoded = None
if attached.content_type and not ('json' in attached.content_type):
error = t(_p(
'settype:message|download|error:not_json',
"The attached message data is not a JSON file!"
))
elif attached.size > 10000:
error = t(_p(
'settype:message|download|error:size',
"The attached message data is too large!"
))
else:
content = await attached.read()
try:
decoded = content.decode('UTF-8')
except UnicodeDecodeError:
error = t(_p(
'settype:message|download|error:decoding',
"Could not decode the message data. Please ensure it is saved with the `UTF-8` encoding."
))
if error is not None:
raise UserInputError(error)
else:
return decoded
@classmethod
def value_to_args(cls, parent_id: ParentID, value: dict, **kwargs):
if not value:
return None
args = {}
args['content'] = value.get('content', "")
if 'embed' in value:
embed = discord.Embed.from_dict(value['embed'])
args['embed'] = embed
if 'embeds' in value:
embeds = []
for embed_data in value['embeds']:
embeds.append(discord.Embed.from_dict(embed_data))
@classmethod
def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs):
if value and any(value.get(key, None) for key in ('content', 'embed', 'embeds')):
data = json.dumps(value)
else:
data = None
return data
@classmethod
def _data_to_value(cls, parent_id: ParentID, data: Optional[str], **kwargs):
if data:
value = json.loads(data)
else:
value = None
return value
@classmethod
async def _parse_string(cls, parent_id: ParentID, string: str, **kwargs):
"""
Provided user string can be downright random.
If it isn't json-formatted, treat it as the content of the message.
If it is, do basic checking on the length and embeds.
"""
string = string.strip()
if not string or string.lower() == 'none':
return None
t = ctx_translator.get().t
error_tip = t(_p(
'settype:message|error_suffix',
"You can view, test, and fix your embed using the online [embed builder]({link})."
)).format(
link="https://glitchii.github.io/embedbuilder/?editor=json"
)
if string.startswith('{') and string.endswith('}'):
# Assume the string is a json-formatted message dict
try:
value = json.loads(string)
except json.JSONDecodeError as err:
error = t(_p(
'settype:message|error:invalid_json',
"The provided message data was not a valid JSON document!\n"
"`{error}`"
)).format(error=str(err))
raise UserInputError(error + '\n' + error_tip)
if not isinstance(value, dict) or not any(value.get(key, None) for key in ('content', 'embed', 'embeds')):
error = t(_p(
'settype:message|error:json_missing_keys',
"Message data must be a JSON object with at least one of the following fields: "
"`content`, `embed`, `embeds`"
))
raise UserInputError(error + '\n' + error_tip)
embed_data = value.get('embed', None)
if not isinstance(embed_data, dict):
error = t(_p(
'settype:message|error:json_embed_type',
"`embed` field must be a valid JSON object."
))
raise UserInputError(error + '\n' + error_tip)
embeds_data = value.get('embeds', [])
if not isinstance(embeds_data, list):
error = t(_p(
'settype:message|error:json_embeds_type',
"`embeds` field must be a list."
))
raise UserInputError(error + '\n' + error_tip)
if embed_data and embeds_data:
error = t(_p(
'settype:message|error:json_embed_embeds',
"Message data cannot include both `embed` and `embeds`."
))
raise UserInputError(error + '\n' + error_tip)
content_data = value.get('content', "")
if not isinstance(content_data, str):
error = t(_p(
'settype:message|error:json_content_type',
"`content` field must be a string."
))
raise UserInputError(error + '\n' + error_tip)
# Validate embeds, which is the most likely place for something to go wrong
embeds = [embed_data] if embed_data else embeds_data
try:
for embed in embeds:
discord.Embed.from_dict(embed)
except Exception as e:
# from_dict may raise a range of possible exceptions.
raw_error = ''.join(
traceback.TracebackException.from_exception(e).format_exception_only()
)
error = t(_p(
'ui:settype:message|error:embed_conversion',
"Could not parse the message embed data.\n"
"**Error:** `{exception}`"
)).format(exception=raw_error)
raise UserInputError(error + '\n' + error_tip)
# At this point, the message will at least successfully convert into MessageArgs
# There are numerous ways it could still be invalid, e.g. invalid urls, or too-long fields
# or the total message content being too long, or too many fields, etc
# This will need to be caught in anything which displays a message parsed from user data.
else:
# Either the string is not json formatted, or the formatting is broken
# Assume the string is a content message
value = {
'content': string
}
return json.dumps(value)
@classmethod
def _format_data(cls, parent_id: ParentID, data: Optional[str], **kwargs):
if not data:
return None
value = cls._data_to_value(parent_id, data, **kwargs)
content = value.get('content', "")
if 'embed' in value or 'embeds' in value or len(content) > 1024:
t = ctx_translator.get().t
formatted = t(_p(
'settype:message|format:too_long',
"Too long to display! See Preview."
))
else:
formatted = content
return formatted

View File

@@ -323,11 +323,10 @@ class Reminders(LionCog):
logger.debug(
f"Executed reminder <rid: {reminder.reminderid}>."
)
except discord.HTTPException:
except discord.HTTPException as e:
await reminder.update(failed=True)
logger.debug(
f"Reminder <rid: {reminder.reminderid}> could not be sent.",
exc_info=True
f"Reminder <rid: {reminder.reminderid}> could not be sent: {e.text}",
)
except Exception:
await reminder.update(failed=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
from enum import Enum
from data import Registry, RowModel, RegisterEnum, Column
from data import Registry, RowModel, RegisterEnum, Column, NULL
from data.columns import Integer, Timestamp, String, Bool
@@ -35,6 +35,9 @@ class RoleMenuData(Registry):
templateid = Integer()
rawmessage = String()
default_price = Integer()
event_log = Bool()
class RoleMenuRole(RowModel):
_tablename_ = 'role_menu_roles'
_cache_ = {}
@@ -66,4 +69,17 @@ class RoleMenuData(Registry):
obtained_at = Timestamp()
transactionid = Integer()
expires_at = Timestamp()
expired_at = Timestamp()
removed_at = Timestamp()
@classmethod
def fetch_expiring_where(cls, *args, **kwargs):
"""
Fetch expiring equip rows.
This returns an awaitable and chainable Select Query.
"""
return cls.fetch_where(
(cls.expires_at != NULL),
(cls.removed_at == NULL),
*args, **kwargs
)

View File

@@ -10,16 +10,14 @@ from settings.setting_types import (
RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting
)
from core.setting_types import MessageSetting
from .data import RoleMenuData
from . import babel
_p = babel._p
# TODO: Write some custom accepts fields
# TODO: The *name* might be an important setting!
class RoleMenuConfig(ModelConfig):
settings = SettingDotDict()
_model_settings = set()
@@ -45,6 +43,10 @@ class RoleMenuConfig(ModelConfig):
def obtainable(self):
return self.get(RoleMenuOptions.Obtainable.setting_id)
@property
def rawmessage(self):
return self.get(RoleMenuOptions.Message.setting_id)
class RoleMenuOptions(SettingGroup):
@RoleMenuConfig.register_model_setting
@@ -56,6 +58,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:name|desc',
"Brief name for this role menu."
)
_accepts = _desc
_long_desc = _p(
'menuset:name|long_desc',
"The role menu name is displayed when selecting the menu in commands, "
@@ -66,6 +69,16 @@ class RoleMenuOptions(SettingGroup):
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.name.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
resp = t(_p(
'menuset:name|set_response',
"This role menu will now be called **{new_name}**."
)).format(new_name=value)
return resp
@RoleMenuConfig.register_model_setting
class Sticky(ModelData, BoolSetting):
setting_id = 'sticky'
@@ -75,6 +88,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:sticky|desc',
"Whether the menu can be used to unequip roles."
)
_accepts = _desc
_long_desc = _p(
'menuset:sticky|long_desc',
"When enabled, members will not be able to remove equipped roles by selecting them in this menu. "
@@ -86,6 +100,22 @@ class RoleMenuOptions(SettingGroup):
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.sticky.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'menuset:sticky|set_response:true',
"Members will no longer be able to remove roles with this menu."
))
else:
resp = t(_p(
'menuset:sticky|set_response:false',
"Members will now be able to remove roles with this menu."
))
return resp
@RoleMenuConfig.register_model_setting
class Refunds(ModelData, BoolSetting):
setting_id = 'refunds'
@@ -95,6 +125,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:refunds|desc',
"Whether removing a role will refund the purchase price for that role."
)
_accepts = _desc
_long_desc = _p(
'menuset:refunds|long_desc',
"When enabled, members who *purchased a role through this role menu* will obtain a full refund "
@@ -107,6 +138,22 @@ class RoleMenuOptions(SettingGroup):
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.refunds.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'menuset:refunds|set_response:true',
"Members will now be refunded when removing a role with this menu."
))
else:
resp = t(_p(
'menuset:refunds|set_response:false',
"Members will no longer be refunded when removing a role with this menu."
))
return resp
@RoleMenuConfig.register_model_setting
class Obtainable(ModelData, IntegerSetting):
setting_id = 'obtainable'
@@ -116,16 +163,37 @@ class RoleMenuOptions(SettingGroup):
'menuset:obtainable|desc',
"The maximum number of roles equippable from this menu."
)
_accepts = _desc
_long_desc = _p(
'menus:obtainable|long_desc',
'menuset:obtainable|long_desc',
"Members will not be able to obtain more than this number of roles from this menu. "
"The counts roles that were not obtained through the rolemenu system."
"This counts roles that were not obtained through the rolemenu system."
)
_notset_str = _p(
'menuset:obtainable|notset',
"Unlimited."
)
_default = None
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.obtainable.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'menuset:obtainable|set_response:set',
"Members will be able to select a maximum of **{value}** roles from this menu."
)).format(value=value)
else:
resp = t(_p(
'menuset:obtainable|set_response:unset',
"Members will be able to select any number of roles from this menu."
))
return resp
@RoleMenuConfig.register_model_setting
class RequiredRole(ModelData, RoleSetting):
setting_id = 'required_role'
@@ -135,6 +203,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:required_role|desc',
"Initial role required to use this menu."
)
_accepts = _desc
_long_desc = _p(
'menuset:required_role|long_desc',
"If set, only members who have the `required_role` will be able to obtain or remove roles using this menu."
@@ -143,3 +212,56 @@ class RoleMenuOptions(SettingGroup):
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.required_roleid.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'menuset:required_role|set_response:set',
"Members will need to have the {role} role to use this menu."
)).format(role=self.formatted)
else:
resp = t(_p(
'menuset:required_role|set_response:unset',
"Any member who can see the menu may use it."
))
return resp
@RoleMenuConfig.register_model_setting
class Message(ModelData, MessageSetting):
setting_id = 'message'
_display_name = _p('menuset:message', "custom_message")
_desc = _p(
'menuset:message|desc',
"Custom message data used to display the menu."
)
_long_desc = _p(
'menuset:message|long_desc',
"This setting determines the body of the menu message, "
"including the message content and the message embed(s). "
"While most easily modifiable through the `Edit Message` button, "
"raw JSON-formatted message data may also be uploaded via command."
)
_default = None
_model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.rawmessage.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'menuset:message|set_response:set',
"The role menu message has been set. Edit through the menu editor."
)).format(value=value.mention)
else:
resp = t(_p(
'menuset:message|set_response:unset',
"The role menu message has been unset. Select a template through the menu editor."
))
return resp

View File

@@ -1,9 +1,44 @@
from meta import LionBot
import json
from typing import Optional, TYPE_CHECKING
import datetime as dt
from collections import defaultdict
import discord
from discord.ui.select import Select, SelectOption
from discord.ui.button import Button, ButtonStyle
from meta import LionBot
from meta.errors import UserInputError, SafeCancellation
from utils.ui import MessageArgs, HookedItem, AsComponents
from utils.lib import utc_now, jumpto, emojikey
from babel.translator import ctx_locale
from modules.economy.cog import Economy, EconomyData, TransactionType
from .data import RoleMenuData as Data
from .data import MenuType
from .menuoptions import RoleMenuConfig
from .roleoptions import RoleMenuRoleConfig
from .templates import templates
from . import logger, babel
if TYPE_CHECKING:
from .cog import RoleMenuCog
_p = babel._p
MISSING = object()
DEFAULT_EMOJIS = "🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🫐 🍓 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🫒 🥦 🥬 🫑 🥒".split()
DEFAULT_EMOJIS_PARTIALS = [discord.PartialEmoji(name=string) for string in DEFAULT_EMOJIS]
class MenuDropdown(HookedItem, Select):
...
class MenuButton(HookedItem, Button):
...
class RoleMenuRole:
@@ -12,31 +47,678 @@ class RoleMenuRole:
self.data = data
self.config = RoleMenuRoleConfig(data.menuroleid, data)
@property
def custom_id(self):
return f"rmrid:{self.data.menuroleid}"
@property
def as_option(self):
return SelectOption(
emoji=self.config.emoji.data or None,
label=self.config.label.value,
value=str(self.data.menuroleid),
description=self.config.description.value,
)
@property
def as_button(self):
@MenuButton(
emoji=self.config.emoji.data or None,
label=self.config.label.value,
custom_id=self.custom_id,
style=ButtonStyle.grey
)
async def menu_button(press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
menu = await RoleMenu.fetch(self.bot, self.data.menuid)
await menu.interactive_selection(press, self.data.menuroleid)
return menu_button
class RoleMenu:
def __init__(self, bot: LionBot, data: Data.RoleMenu, roles):
# Cache of messages with listening menus attached
attached_menus = defaultdict(dict) # guildid -> messageid -> menuid
# Registry of persistent Views for given menus
menu_views = {} # menuid -> View
# Persistent cache of menus
_menus = {} # menuid -> Menu
def __init__(self, bot: LionBot, data: Data.RoleMenu, rolemap):
self.bot = bot
self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog')
self.data = data
self.config = RoleMenuConfig(data.menuid, data)
self.roles: list[RoleMenuRole] = roles
self.rolemap: dict[int, RoleMenuRole] = rolemap
self.roles = list(rolemap.values())
self._message = None
self._message = MISSING
@property
def _view(self) -> Optional[discord.ui.View]:
"""
Active persistent View for this menu.
"""
return self.menu_views.get(self.data.menuid, None)
@property
def message(self):
if self._message is MISSING:
raise ValueError("Cannot access menu message before fetch")
else:
return self._message
@property
def jump_link(self):
if self.data.messageid:
link = jumpto(
self.data.guildid,
self.data.channelid,
self.data.messageid
)
else:
link = None
return link
@property
def managed(self):
"""
Whether the menu message is owned by the bot.
Returns True if the menu is unattached.
"""
if self._message is MISSING:
# Unknown, but send falsey value
managed = None
elif self._message is None:
managed = True
elif self._message.author is self._message.guild.me:
managed = True
else:
managed = False
return managed
@classmethod
async def fetch(cls, bot: LionBot, menuid: int):
"""
Fetch the requested menu by id, applying registry cache where possible.
"""
if (menu := cls._menus.get(menuid, None)) is None:
cog = bot.get_cog('RoleMenuCog')
data = await cog.data.RoleMenu.fetch(menuid)
role_rows = await cog.data.RoleMenuRole.fetch_where(menuid=menuid).order_by('menuroleid')
rolemap = {row.menuroleid: RoleMenuRole(bot, row) for row in role_rows}
menu = cls(bot, data, rolemap)
cls._menus[menuid] = menu
return menu
@classmethod
async def create(cls, bot: LionBot, **data_args):
cog = bot.get_cog('RoleMenuCog')
data = await cog.data.RoleMenu.create(
**data_args
)
menu = cls(bot, data, {})
cls._menus[data.menuid] = menu
await menu.attach()
return menu
async def fetch_message(self, refresh=False):
"""
Fetch the message the menu is attached to.
"""
if refresh or self._message is MISSING:
if self.data.messageid is None:
_message = None
else:
_message = None
channelid = self.data.channelid
channel = self.bot.get_channel(channelid)
if channel is not None:
try:
_message = await channel.fetch_message(self.data.messageid)
except discord.NotFound:
pass
except discord.Forbidden:
pass
except discord.HTTPException:
# Something unexpected went wrong, leave the data alone for now
logger.exception("Something unexpected occurred while fetching the menu message")
raise
if _message is None:
await self.data.update(messageid=None)
self._message = _message
return self._message
async def fetch_message(self):
...
def emoji_map(self):
emoji_map = {}
for mrole in self.roles:
emoji = mrole.config.emoji.as_partial
if emoji is not None:
emoji_map[emoji] = mrole.data.menuroleid
return emoji_map
async def reload(self):
await self.data.refresh()
async def attach(self):
"""
Start listening for menu selection events.
"""
if self.data.messageid:
self.attached_menus[self.data.guildid][self.data.messageid] = self.data.menuid
if self.data.menutype is not MenuType.REACTION:
view = await self.make_view()
if view is not None:
self.menu_views[self.data.menuid] = view
self.bot.add_view(view)
elif self.data.menutype is MenuType.REACTION:
pass
def detach(self):
"""
Stop listening for menu selection events.
"""
view = self.menu_views.pop(self.data.menuid, None)
if view is not None:
view.stop()
if (mid := self.data.messageid) is not None:
self.attached_menus[self.data.guildid].pop(mid, None)
async def delete(self):
self.detach()
self._menus.pop(self.data.menuid, None)
# Delete the menu, along with the message if it is self-managed.
message = await self.fetch_message()
if message and message.author is message.guild.me:
try:
await message.delete()
except discord.HTTPException:
# This should never really fail since we own the message
# But it is possible the message was externally deleted and we never updated message cache
# So just ignore quietly
pass
# Cancel any relevant expiry tasks (before we delete data which will delete the equip rows)
expiring = await self.cog.data.RoleMenuHistory.fetch_expiring_where(menuid=self.data.menuid)
if expiring:
await self.cog.cancel_expiring_tasks(*(row.equipid for row in expiring))
await self.data.delete()
async def reload_roles(self):
"""
Fetches and re-initialises the MenuRoles for this Menu.
"""
roledata = self.bot.get_cog('RoleMenuCog').data.RoleMenuRole
role_rows = await roledata.fetch_where(menuid=self.data.menuid)
self.roles = [RoleMenuRole(self.bot, row) for row in role_rows]
role_rows = await roledata.fetch_where(menuid=self.data.menuid).order_by('menuroleid')
self.rolemap = {row.menuroleid: RoleMenuRole(self.bot, row) for row in role_rows}
self.roles = list(self.rolemap.values())
async def make_view(self):
...
async def update_message(self):
"""
Update the (managed) message the menu is attached to.
async def make_args(self):
...
Does nothing if there is not message or it is not bot-managed.
"""
self.detach()
message = await self.fetch_message()
if message is not None and self.managed:
args = await self.make_args()
view = await self.make_view()
try:
await message.edit(**args.edit_args, view=view)
await self.attach()
except discord.NotFound:
await self.data.update(messageid=None)
self._message = None
except discord.HTTPException as e:
t = self.bot.translator.t
error = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'rolemenu|menu_message|error|title',
'ROLE MENU DISPLAY ERROR'
)),
description=t(_p(
'rolemenu|menu_message|error|desc',
"A critical error occurred trying to display this role menu.\n"
"Error: `{error}`."
)).format(error=e.text)
)
try:
await message.edit(
embed=error
)
except discord.HTTPException:
# There's really something wrong
# Nothing we can safely do.
pass
pass
async def update_reactons(self):
"""
Attempt to update the reactions on a REACTION type menu.
Does nothing if the menu is not REACTION type.
Will raise `SafeCancellation` and stop if a reaction fails.
"""
message = await self.fetch_message()
if message is not None and self.data.menutype is MenuType.REACTION:
# First remove any of my reactions that are no longer relevant
required = {
emojikey(mrole.config.emoji.as_partial) for mrole in self.roles if mrole.data.emoji
}
for reaction in message.reactions:
if reaction.me and (emojikey(reaction.emoji) not in required):
try:
await message.remove_reaction(reaction.emoji, message.guild.me)
except discord.HTTPException:
pass
# Then add any extra reactions that are missing
existing_mine = {
emojikey(reaction.emoji) for reaction in message.reactions if reaction.me
}
existing = {
emojikey(reaction.emoji) for reaction in message.reactions
}
for mrole in self.roles:
emoji = mrole.config.emoji.as_partial
if emoji is not None and emojikey(emoji) not in existing_mine:
try:
await message.add_reaction(emoji)
except discord.HTTPException:
if emojikey(emoji) not in existing:
t = self.bot.translator.t
raise SafeCancellation(
t(_p(
'rolemenu|update_reactions|error',
"Could not add the {emoji} reaction, perhaps I do not "
"have access to this emoji! Reactions will need to be added "
"manually."
)).format(emoji=emoji)
)
else:
# We can't react with this emoji, but it does exist on the message
# Just ignore the error and continue
continue
async def repost_to(self, destination):
# Set the current message to be deleted if it is a managed message.
# Don't delete until after we have successfully moved the menu though.
if self.managed and (message := self.message):
to_delete = message
else:
to_delete = None
# Now try and post the message in the new channel
args = await self.make_args()
view = await self.make_view()
new_message = await destination.send(**args.send_args, view=view or discord.utils.MISSING)
# Stop listening to events on the current message (if it exists)
self.detach()
await self.data.update(channelid=destination.id, messageid=new_message.id)
self._message = new_message
await self.attach()
if to_delete:
# Attempt to delete the original message
try:
await to_delete.delete()
except discord.HTTPException:
pass
async def _make_button_view(self):
buttons = [mrole.as_button for mrole in self.roles]
return AsComponents(*buttons, timeout=None)
async def _make_dropdown_view(self):
t = self.bot.translator.t
placeholder = t(_p(
'ui:rolemenu_dropdown|placeholder',
"Select Roles"
))
options = [mrole.as_option for mrole in self.roles]
@MenuDropdown(
custom_id=f"menuid:{self.data.menuid}",
placeholder=placeholder,
options=options,
min_values=0, max_values=1
)
async def menu_dropdown(selection: discord.Interaction, selected: Select):
if selected.values:
await selection.response.defer(thinking=True, ephemeral=True)
menuroleid = int(selected.values[0])
menu = await self.fetch(self.bot, self.data.menuid)
await menu.interactive_selection(selection, menuroleid)
else:
await selection.response.defer(thinking=False)
return AsComponents(menu_dropdown, timeout=None)
async def make_view(self) -> Optional[discord.ui.View]:
"""
Create the appropriate discord.View for this menu.
May be None if the menu has no roles or is a REACTION menu.
"""
lguild = await self.bot.core.lions.fetch_guild(self.data.guildid)
ctx_locale.set(lguild.locale)
if not self.roles:
view = None
elif self.data.menutype is MenuType.REACTION:
view = None
elif self.data.menutype is MenuType.DROPDOWN:
view = await self._make_dropdown_view()
elif self.data.menutype is MenuType.BUTTON:
view = await self._make_button_view()
return view
async def make_args(self) -> MessageArgs:
"""
Generate the message arguments for this menu.
"""
if (tid := self.data.templateid) is not None:
# Apply template
template = templates[tid]
args = await template.render_menu(self)
else:
raw = self.data.rawmessage
data = json.loads(raw)
args = MessageArgs(
content=data.get('content', ''),
embed=discord.Embed.from_dict(data['embed']) if 'embed' in data else None
)
return args
def unused_emojis(self, include_defaults=True):
"""
Fetch the next emoji on the message that is not already assigned to a role.
Checks custom emojis by PartialEmoji equality (i.e. by id).
If no reaction exists, uses a default emoji.
"""
if self.message:
message_emojis = [reaction.emoji for reaction in self.message.reactions]
else:
message_emojis = []
if self.data.menutype is MenuType.REACTION:
valid_emojis = (*message_emojis, *DEFAULT_EMOJIS_PARTIALS)
else:
valid_emojis = message_emojis
menu_emojis = {emojikey(mrole.config.emoji.as_partial) for mrole in self.roles}
for emoji in valid_emojis:
if emojikey(emoji) not in menu_emojis:
yield str(emoji)
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
mrole = self.rolemap.get(menuroleid, None)
if mrole is None:
raise ValueError(f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN.")
guild = member.guild
t = self.bot.translator.t
role = guild.get_role(mrole.data.roleid)
if role is None:
# This role no longer exists, nothing we can do
raise UserInputError(
t(_p(
'rolemenu|error:role_gone',
"This role no longer exists!"
))
)
if role in member.roles:
# Member already has the role, deselection case.
if self.config.sticky.value:
# Cannot deselect
raise UserInputError(
t(_p(
'rolemenu|deselect|error:sticky',
"{role} is a sticky role, you cannot remove it with this menu!"
)).format(role=role.mention)
)
conn = await self.bot.db.get_connection()
async with conn.transaction():
# Remove the role
try:
await member.remove_roles(role)
except discord.Forbidden:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:perms',
"I don't have enough permissions to remove this role from you!"
))
)
except discord.HTTPException:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:discord',
"An unknown error occurred removing your role! Please try again later."
))
)
# Update history
now = utc_now()
history = await self.cog.data.RoleMenuHistory.table.update_where(
menuid=self.data.menuid,
roleid=role.id,
userid=member.id,
removed_at=None,
).set(removed_at=now)
await self.cog.cancel_expiring_tasks(*(row.equipid for row in history))
# Refund if required
transactionids = [row['transactionid'] for row in history]
if self.config.refunds.value and any(transactionids):
transactionids = [tid for tid in transactionids if tid]
economy: Economy = self.bot.get_cog('Economy')
refunded = await economy.data.Transaction.refund_transactions(*transactionids)
total_refund = sum(row.amount + row.bonus for row in refunded)
else:
total_refund = 0
# Ack the removal
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|deslect|success|title',
"Role removed"
))
)
if total_refund:
embed.description = t(_p(
'rolemenu|deselect|success:refund|desc',
"You have removed {role}, and been refunded {coin} **{amount}**."
)).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=total_refund)
else:
embed.description = t(_p(
'rolemenu|deselect|success:norefund|desc',
"You have unequipped {role}."
)).format(role=role.mention)
return embed
else:
# Member does not have the role, selection case.
required = self.config.required_role.value
if required is not None:
# Check member has the required role
if required not in member.roles:
raise UserInputError(
t(_p(
'rolemenu|select|error:required_role',
"You need to have the {role} role to use this!"
)).format(role=required.mention)
)
obtainable = self.config.obtainable.value
if obtainable is not None:
# Check shared roles
menu_roleids = {mrole.data.roleid for mrole in self.roles}
member_roleids = {role.id for role in member.roles}
common = len(menu_roleids.intersection(member_roleids))
if common >= obtainable:
raise UserInputError(
t(_p(
'rolemenu|select|error:max_obtainable',
"You already have the maximum of {obtainable} roles from this menu!"
)).format(obtainable=obtainable)
)
price = mrole.config.price.value
if price:
# Check member balance
# TODO: More transaction safe (or rather check again after transaction)
await lion.data.refresh()
balance = lion.data.coins
if balance < price:
raise UserInputError(
t(_p(
'rolemenu|select|error:insufficient_funds',
"The role {role} costs {coin}**{cost}**,"
"but you only have {coin}**{balance}**!"
)).format(
role=role.mention,
coin=self.bot.config.emojis.coin,
cost=price,
balance=balance,
)
)
conn = await self.bot.db.get_connection()
async with conn.transaction():
try:
await member.add_roles(role)
except discord.Forbidden:
raise UserInputError(
t(_p(
'rolemenu|select|error:perms',
"I don't have enough permissions to give you this role!"
))
)
except discord.HTTPException:
raise UserInputError(
t(_p(
'rolemenu|select|error:discord',
"An unknown error occurred while assigning your role! "
"Please try again later."
))
)
now = utc_now()
# Create transaction if applicable
if price:
economy: Economy = self.bot.get_cog('Economy')
tx = await economy.data.Transaction.execute_transaction(
transaction_type=TransactionType.OTHER,
guildid=guild.id, actorid=member.id,
from_account=member.id, to_account=None,
amount=price
)
tid = tx.transactionid
else:
tid = None
# Calculate expiry
duration = mrole.config.duration.value
if duration is not None:
expiry = now + dt.timedelta(seconds=duration)
else:
expiry = None
# Add to equip history
equip = await self.cog.data.RoleMenuHistory.create(
menuid=self.data.menuid, roleid=role.id,
userid=member.id,
obtained_at=now,
transactionid=tid,
expires_at=expiry
)
await self.cog.schedule_expiring(equip)
# Ack the selection
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|select|success|title',
"Role equipped"
))
)
if price > 0:
embed.description = t(_p(
'rolemenu|select|success:purchase|desc',
"You have purchased the role {role} for {coin}**{amount}**"
)).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=price)
else:
embed.description = t(_p(
'rolemenu|select|success:nopurchase|desc',
"You have equipped the role {role}"
)).format(role=role.mention)
if expiry is not None:
embed.description += '\n' + t(_p(
'rolemenu|select|expires_at',
"The role will expire at {timestamp}."
)).format(
timestamp=discord.utils.format_dt(expiry)
)
return embed
async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int):
"""
Handle a component interaction callback for this menu.
Assumes the interaction has already been responded to (ephemerally).
"""
member = interaction.user
guild = interaction.guild
if not isinstance(member, discord.Member):
# Occasionally Discord drops the ball on user type. This manually fetches the guild member.
member = await guild.fetch_member(member.id)
# Localise to the member's locale
lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member)
ctx_locale.set(lion.private_locale(interaction))
result = await self._handle_selection(lion, member, menuroleid)
await interaction.edit_original_response(embed=result)
async def handle_reaction(self, reaction_payload: discord.RawReactionActionEvent):
"""
Handle a raw reaction event on a message the menu is attached to.
Ignores the event if it is not relevant.
"""
guild = self.bot.get_guild(reaction_payload.guild_id)
channel = self.bot.get_channel(reaction_payload.channel_id)
if guild and channel:
emoji_map = self.emoji_map()
menuroleid = emoji_map.get(reaction_payload.emoji, None)
if menuroleid is not None:
member = reaction_payload.member
if not member:
member = await guild.fetch_member(reaction_payload.user_id)
if member.bot:
return
lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member)
ctx_locale.set(lion.private_locale())
try:
embed = await self._handle_selection(lion, member, menuroleid)
except UserInputError as e:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=e.msg
)
t = self.bot.translator.t
content = t(_p(
'rolemenu|content:reactions',
"[Click here]({jump_link}) to jump back."
)).format(jump_link=jumpto(guild.id, channel.id, reaction_payload.message_id))
try:
await member.send(content=content, embed=embed)
except discord.HTTPException:
pass

View File

@@ -1,9 +1,14 @@
import discord
from settings import ModelData
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
from settings.setting_types import (
RoleSetting, BoolSetting, StringSetting, DurationSetting
RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting
)
from core.setting_types import CoinSetting
from utils.ui import AButton, AsComponents
from meta.errors import UserInputError
from babel.translator import ctx_translator
from .data import RoleMenuData
from . import babel
@@ -59,26 +64,49 @@ class RoleMenuRoleOptions(SettingGroup):
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.roleid.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'roleset:role|set_response:set',
"This menu item will now give the role {role}."
)).format(role=self.formatted)
return resp
@RoleMenuRoleConfig.register_model_setting
class Label(ModelData, StringSetting):
setting_id = 'role'
setting_id = 'label'
_display_name = _p('roleset:label', "label")
_desc = _p(
'roleset:label|desc',
"A short button label for this role."
)
_accepts = _desc
_long_desc = _p(
'roleset:label|long_desc',
"A short name for this role, to be displayed in button labels, dropdown titles, and some menu layouts. "
"By default uses the Discord role name."
)
_quote = False
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.label.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
resp = t(_p(
'roleset:role|set_response',
"This menu role is now called `{value}`."
)).format(value=self.data)
return resp
@RoleMenuRoleConfig.register_model_setting
class Emoji(ModelData, StringSetting):
class Emoji(ModelData, EmojiSetting):
setting_id = 'emoji'
_display_name = _p('roleset:emoji', "emoji")
@@ -96,6 +124,56 @@ class RoleMenuRoleOptions(SettingGroup):
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.emoji.name
@property
def test_button(self):
if self.data:
button = AButton(emoji=self.data)
button.disabled = True
@button
async def emoji_test_callback(press, butt):
await press.response.defer()
else:
button = None
return button
@classmethod
async def _parse_string(cls, parent_id, string: str, interaction: discord.Interaction = None, **kwargs):
emojistr = await super()._parse_string(parent_id, string, interaction=interaction, **kwargs)
if emojistr and interaction is not None:
# Use the interaction to test
button = AButton(emoji=emojistr)
button.disabled = True
view = AsComponents(button)
try:
await interaction.edit_original_response(
content=f"Testing Emoji {emojistr}",
view=view,
)
except discord.HTTPException:
t = interaction.client.translator.t
raise UserInputError(t(_p(
'roleset:emoji|error:test_emoji',
"The selected emoji `{emoji}` is invalid or has been deleted."
)).format(emoji=emojistr))
return emojistr
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'roleset:emoji|set_response:set',
"The menu role emoji is now {emoji}."
)).format(emoji=self.as_partial)
else:
resp = t(_p(
'roleset:emoji|set_response:unset',
"The menu role emoji has been removed."
))
return resp
@RoleMenuRoleConfig.register_model_setting
class Description(ModelData, StringSetting):
setting_id = 'description'
@@ -105,15 +183,34 @@ class RoleMenuRoleOptions(SettingGroup):
'roleset:description|desc',
"A longer description of this role."
)
_accepts = _desc
_long_desc = _p(
'roleset:description|long_desc',
"The description is displayed under the role label in dropdown style menus. "
"It may also be used as a substitution key in custom role selection responses."
)
_quote = False
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.description.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'roleset:description|set_response:set',
"The role description has been set."
))
else:
resp = t(_p(
'roleset:description|set_response:unset',
"The role description has been removed."
))
return resp
@RoleMenuRoleConfig.register_model_setting
class Price(ModelData, CoinSetting):
setting_id = 'price'
@@ -127,10 +224,30 @@ class RoleMenuRoleOptions(SettingGroup):
'roleset:price|long_desc',
"How much the role costs when selected, in LionCoins."
)
_accepts = _p(
'roleset:price|accepts',
"Amount of coins that the role costs."
)
_default = 0
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.price.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'roleset:price|set_response:set',
"This role will now cost {price} to equip."
)).format(price=self.formatted)
else:
resp = t(_p(
'roleset:price|set_response:unset',
"This role will now be free to equip from this role menu."
))
return resp
@RoleMenuRoleConfig.register_model_setting
class Duration(ModelData, DurationSetting):
setting_id = 'duration'
@@ -145,5 +262,26 @@ class RoleMenuRoleOptions(SettingGroup):
"Allows creation of 'temporary roles' which expire a given time after being equipped. "
"Refunds will not be given upon expiry."
)
_notset_str = _p(
'roleset:duration|notset',
"Forever."
)
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.duration.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'roleset:duration|set_response:set',
"This role will now expire after {duration}."
)).format(duration=self.formatted)
else:
resp = t(_p(
'roleset:duration|set_response:unset',
"This role will no longer expire after being selected."
))
return resp

View File

@@ -13,7 +13,7 @@ _p = babel._p
DEFAULT_EMOJI = '🔲'
templates = {}
templates: dict[int, 'Template'] = {}
class Template:
@@ -68,7 +68,7 @@ async def simple_template(menu) -> MessageArgs:
duration = menurole.config.duration
if emoji.data:
parts.append(emoji.formatted)
parts.append(emoji.data)
parts.append(role.formatted)
@@ -114,7 +114,7 @@ async def twocolumn_template(menu) -> MessageArgs:
)
for block in blocks:
block_lines = [
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**"
for menurole in block
]
if block_lines:
@@ -151,7 +151,7 @@ async def threecolumn_template(menu) -> MessageArgs:
)
for block in blocks:
block_lines = [
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**"
for menurole in block
]
if block_lines:
@@ -188,7 +188,7 @@ async def shop_template(menu) -> MessageArgs:
parts.append("|")
if emoji.data:
parts.append(emoji.formatted)
parts.append(emoji.data)
parts.append(role.formatted)

View File

@@ -8,9 +8,12 @@ from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, Select, RoleSelect, ChannelSelect, SelectOption
from meta import LionBot, conf
from meta.errors import UserInputError
from utils.lib import utc_now, MessageArgs, error_embed
from utils.ui import MessageUI, ConfigEditor, FastModal, error_handler_for, ModalRetryUI, MsgEditor
from meta.errors import UserInputError, ResponseTimedOut, SafeCancellation
from utils.lib import utc_now, MessageArgs, error_embed, tabulate
from utils.ui import (
MessageUI, ConfigEditor, FastModal, error_handler_for,
ModalRetryUI, MsgEditor, Confirm, HookedItem, AsComponents,
)
from babel.translator import ctx_locale
from wards import equippable_role
@@ -32,6 +35,10 @@ class RoleEditorInput(FastModal):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class AChannelSelect(HookedItem, ChannelSelect):
...
class EditorMode(Enum):
OPTIONS = 0
ROLES = 1
@@ -39,6 +46,8 @@ class EditorMode(Enum):
class MenuEditor(MessageUI):
_listening = {} # (channelid, callerid) -> active MenuEditor
def _init_children(self):
# HACK to stop ViewWeights complaining that this UI has too many children
# Children will be correctly initialised after parent init.
@@ -51,40 +60,76 @@ class MenuEditor(MessageUI):
self.bot = bot
self.menu = menu
self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data
self.listen_key = None
# UI State
self.mode: EditorMode = EditorMode.ROLES
self.page_count: int = 1
self.pagen: int = 0
self.page_block: list[RoleMenuRole] = []
self._preview: Optional[discord.Interaction] = None
# ----- UI API -----
async def dispatch_update(self):
async def update_preview(self):
"""
Broadcast that the menu has changed.
This updates the preview, and tells the menu itself to update any linked messages.
Update the preview message if it exists.
"""
await self.menu.reload()
if self._preview is not None:
args = await self._preview_args()
args = await self.menu.make_args()
view = await self.menu.make_view()
try:
await self._preview.edit_original_response(**args.edit_args)
await self._preview.edit_original_response(**args.edit_args, view=view)
except discord.NotFound:
self._preview = None
except discord.HTTPException as e:
# Due to emoji validation on creation and message edit validation,
# This should be very rare.
# Might happen if e.g. a custom emoji is deleted between opening the editor
# and showing the preview.
# Just show the error to the user and let them deal with it or rerun the editor.
t = self.bot.translator.t
title = t(_p(
'ui:menu_editor|preview|error:title',
"Display Error!"
))
desc = t(_p(
'ui:menu_editor|preview|error:desc',
"Failed to display preview!\n"
"**Error:** `{exception}`"
)).format(
exception=e.text
)
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=title,
description=desc
)
try:
await self._preview.edit_original_response(embed=embed)
except discord.HTTPException:
# If we can't even edit the preview message now, something is probably wrong with the connection
# Just silently ignore
pass
async def _preview_args(self):
if (tid := self.menu.data.templateid) is not None:
# Apply template
template = templates[tid]
args = await template.render_menu(self.menu)
else:
raw = self.menu.data.rawmessage
data = json.loads(raw)
args = MessageArgs(
content=data.get('content', ''),
embed=discord.Embed.from_dict(data['embed'])
)
return args
async def cleanup(self):
self._listening.pop(self.listen_key, None)
await super().cleanup()
async def run(self, interaction: discord.Interaction, **kwargs):
self.listen_key = (interaction.channel.id, interaction.user.id, self.menu.data.menuid)
existing = self._listening.get(self.listen_key, None)
if existing:
await existing.quit()
self._listening[self.listen_key] = self
await super().run(interaction, **kwargs)
async def quit(self):
if self._preview is not None and not self._preview.is_expired():
try:
await self._preview.delete_original_response()
except discord.HTTPException:
pass
await super().quit()
# ----- Components -----
# -- Options Components --
@@ -154,8 +199,6 @@ class MenuEditor(MessageUI):
# Write settings
for instance in modified:
await instance.write()
# Propagate an update
await self.dispatch_update()
# Refresh the UI
await self.refresh(thinking=interaction)
else:
@@ -182,7 +225,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.sticky
instance.value = not instance.value
await instance.write()
await self.dispatch_update()
await self.refresh(thinking=press)
async def sticky_button_refresh(self):
@@ -207,7 +249,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.refunds
instance.value = not instance.value
await instance.write()
await self.dispatch_update()
await self.refresh(thinking=press)
async def refunds_button_refresh(self):
@@ -215,7 +256,7 @@ class MenuEditor(MessageUI):
button = self.refunds_button
button.label = t(_p(
'ui:menu_editor|button:refunds|label',
"Refunds"
"Toggle Refunds"
))
if self.menu.config.refunds.value:
button.style = ButtonStyle.blurple
@@ -238,7 +279,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.required_role
instance.data = new_data
await instance.write()
await self.dispatch_update()
await self.refresh(thinking=selection)
async def reqroles_menu_refresh(self):
@@ -300,6 +340,7 @@ class MenuEditor(MessageUI):
@modal.submit_callback()
async def save_options(interaction: discord.Interaction):
await interaction.response.defer(thinking=True, ephemeral=True)
modified = []
for instance, field, original in zip(instances, fields, originals):
if field.value != original:
@@ -308,25 +349,32 @@ class MenuEditor(MessageUI):
if not userstr:
new_data = None
else:
new_data = await instance._parse_string(instance.parent_id, userstr)
new_data = await instance._parse_string(instance.parent_id, userstr, interaction=interaction)
instance.data = new_data
modified.append(instance)
if modified:
# All fields have been parsed, it is safe to respond
await interaction.response.defer(thinking=True, ephemeral=True)
# Write settings
for instance in modified:
await instance.write()
# Propagate an update
await self.dispatch_update()
# Refresh the UI
await self.refresh(thinking=interaction)
await self.update_preview()
await self.menu.update_message()
if self.menu.data.menutype is MenuType.REACTION:
try:
await self.menu.update_reactons()
except SafeCancellation as e:
await interaction.followup.send(
embed=discord.Embed(
colour=discord.Colour.brand_red(),
description=e.msg
),
ephemeral=True
)
else:
# Nothing was modified, quietly accept
await interaction.response.defer(thinking=False)
await interaction.delete_original_response()
await interaction.response.send_modal(modal)
await self.dispatch_update()
# Add Roles Menu
@select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25)
@@ -369,21 +417,35 @@ class MenuEditor(MessageUI):
)))
# Create roles
# TODO: Emoji generation
emojis = self.menu.unused_emojis(include_defaults=(self.menu.data.menutype is MenuType.REACTION))
rows = await self.data.RoleMenuRole.table.insert_many(
('menuid', 'roleid', 'label'),
*((self.menu.data.menuid, role.id, role.name[:100]) for role in to_create.values())
('menuid', 'roleid', 'label', 'emoji'),
*(
(self.menu.data.menuid, role.id, role.name[:100], next(emojis, None))
for role in to_create.values()
)
).with_adapter(self.data.RoleMenuRole._make_rows)
mroles = [RoleMenuRole(self.bot, row) for row in rows]
single = single if single is not None else mroles[0]
await self.dispatch_update()
if len(roles) == 1:
await self._edit_menu_role(selection, single)
await self.refresh()
else:
await selection.response.defer()
await self.refresh()
await self.menu.reload_roles()
if self.menu.data.name == 'Untitled':
# Hack to name an anonymous menu
# TODO: Formalise this
await self.menu.data.update(name=roles[0].name)
await self.refresh()
await self.update_preview()
await self.menu.update_message()
if self.menu.data.menutype is MenuType.REACTION:
try:
await self.menu.update_reactons()
except SafeCancellation as e:
raise UserInputError(e.msg)
async def add_roles_menu_refresh(self):
t = self.bot.translator.t
@@ -395,6 +457,7 @@ class MenuEditor(MessageUI):
def _role_option(self, menurole: RoleMenuRole):
return SelectOption(
emoji=menurole.config.emoji.data or None,
label=menurole.config.label.value,
value=str(menurole.data.menuroleid),
description=menurole.config.description.value,
@@ -435,7 +498,11 @@ class MenuEditor(MessageUI):
if menuroleids:
await selection.response.defer(thinking=True, ephemeral=True)
await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids)
await self.dispatch_update()
await self.menu.reload_roles()
await self.refresh(thinking=selection)
await self.update_preview()
await self.menu.update_message()
else:
await selection.response.defer(thinking=False)
@@ -468,7 +535,7 @@ class MenuEditor(MessageUI):
raise UserInputError(
t(_p(
'ui:menu_editor|button:style|error:non-managed',
"Cannot change the style of a menu attached to a message I did not send! Please RePost first."
"Cannot change the style of a menu attached to a message I did not send! Please repost first."
))
)
@@ -495,8 +562,8 @@ class MenuEditor(MessageUI):
Select one of Reaction Roles / Dropdown / Button
"""
t = self.bot.translator.t
value = int(selected.values[0])
menutype = MenuType(value)
value = selected.values[0]
menutype = MenuType[value]
if menutype is not self.menu.data.menutype:
# A change is requested
if menutype is MenuType.REACTION:
@@ -521,8 +588,9 @@ class MenuEditor(MessageUI):
)
await selection.response.defer(thinking=True, ephemeral=True)
await self.menu.data.update(menutype=menutype)
await self.dispatch_update()
await self.refresh(thinking=selection)
await self.update_preview()
await self.menu.update_message()
else:
await selection.response.defer()
@@ -540,7 +608,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:reaction|desc',
"Roles are represented compactly as clickable reactions on a message."
)),
value=str(MenuType.REACTION.value),
value=str(MenuType.REACTION.name),
default=(self.menu.data.menutype is MenuType.REACTION)
),
SelectOption(
@@ -549,7 +617,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:button|desc',
"Roles are represented in 5 rows of 5 buttons, each with an emoji and label."
)),
value=str(MenuType.BUTTON.value),
value=str(MenuType.BUTTON.name),
default=(self.menu.data.menutype is MenuType.BUTTON)
),
SelectOption(
@@ -558,7 +626,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:dropdown|desc',
"Roles are selectable from a dropdown menu below the message."
)),
value=str(MenuType.DROPDOWN.value),
value=str(MenuType.DROPDOWN.name),
default=(self.menu.data.menutype is MenuType.DROPDOWN)
)
]
@@ -566,10 +634,12 @@ class MenuEditor(MessageUI):
async def _editor_callback(self, new_data):
raws = json.dumps(new_data)
await self.menu.data.update(rawmessage=raws)
await self.dispatch_update()
await self.update_preview()
await self.menu.update_message()
async def _message_editor(self, interaction: discord.Interaction):
# Spawn the message editor with the current rawmessage data.
# If the rawmessage data is empty, use the current template instead.
editor = MsgEditor(
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
)
@@ -608,12 +678,14 @@ class MenuEditor(MessageUI):
# Spawn editor
await self._message_editor(selection)
await self.dispatch_update()
await self.refresh()
await self.update_preview()
await self.menu.update_message()
else:
await self.menu.data.update(templateid=templateid)
await self.dispatch_update()
await self.refresh(thinking=selection)
await self.update_preview()
await self.menu.update_message()
else:
await selection.response.defer()
@@ -646,24 +718,122 @@ class MenuEditor(MessageUI):
# -- Common Components --
# Delete Menu Button
# Quit Button
@button(label="DELETE_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
async def delete_button(self, press: discord.Interaction, pressed: Button):
"""
Confirm menu deletion, and delete.
"""
t = self.bot.translator.t
confirm_msg = t(_p(
'ui:menu_editor|button:delete|confirm|title',
"Are you sure you want to delete this menu? This is not reversible!"
))
confirm = Confirm(confirm_msg, self._callerid)
confirm.confirm_button.label = t(_p(
'ui:menu_editor|button:delete|confirm|button:yes',
"Yes, Delete Now"
))
confirm.confirm_button.style = ButtonStyle.red
confirm.cancel_button.label = t(_p(
'ui:menu_editor|button:delete|confirm|button:no',
"No, Go Back"
))
confirm.cancel_button.style = ButtonStyle.green
try:
result = await confirm.ask(press, ephemeral=True)
except ResponseTimedOut:
result = False
if result:
await self.menu.delete()
await self.quit()
async def delete_button_refresh(self):
t = self.bot.translator.t
button = self.delete_button
button.label = t(_p(
'ui:menu_editor|button:delete|label',
"Delete Menu"
))
# Quit Button
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Close the UI. This should also close all children.
"""
await press.response.defer(thinking=False)
await self.quit()
# Page Buttons
@button(emoji=conf.emojis.forward)
async def next_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen += 1
await self.refresh()
@button(emoji=conf.emojis.backward)
async def prev_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen -= 1
await self.refresh()
# Page left Button
# Edit Message Button
@button(label="EDIT_MSG_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_msg_button(self, press: discord.Interaction, pressed: Button):
# Set the templateid to None if it isn't already
# And initialise the rawmessage if it needs it.
if (templateid := self.menu.data.templateid) is not None:
update_args = {'templateid': None}
if not self.menu.data.rawmessage:
template = templates[templateid]
margs = await template.render_menu(self.menu)
raw = {
'content': margs.kwargs.get('content', ''),
}
if 'embed' in margs.kwargs:
raw['embed'] = margs.kwargs['embed'].to_dict()
rawjson = json.dumps(raw)
update_args['rawmessage'] = rawjson
await self.menu.data.update(**update_args)
# At this point we are certain the menu is in custom mode and has a rawmessage
# Spawn editor
await self._message_editor(press)
await self.refresh()
await self.update_preview()
await self.menu.update_message()
async def edit_msg_button_refresh(self):
t = self.bot.translator.t
button = self.edit_msg_button
button.label = t(_p(
'ui:menu_editor|button:edit_msg|label',
"Edit Message"
))
# Disable the button if we are on a non-managed message
button.disabled = not self.menu.managed
# Preview Button
@button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def preview_button(self, press: discord.Interaction, pressed: Button):
"""
Display or update the preview message.
"""
args = await self._preview_args()
args = await self.menu.make_args()
view = await self.menu.make_view()
if self._preview is not None:
try:
await self._preview.delete_original_response()
except discord.HTTPException:
pass
self._preview = None
await press.response.send_message(**args.send_args, ephemeral=True)
await press.response.send_message(
**args.send_args,
view=view or discord.utils.MISSING,
ephemeral=True
)
self._preview = press
async def preview_button_refresh(self):
@@ -675,25 +845,237 @@ class MenuEditor(MessageUI):
))
# Repost Menu Button
@button(label="REPOST_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def repost_button(self, press: discord.Interaction, pressed: Button):
"""
Repost the menu in a selected channel.
Pops up a minimal channel selection UI, asking where they want to post it.
"""
t = self.bot.translator.t
@AChannelSelect(
placeholder=t(_p(
'ui:menu_editor|button:repost|widget:repost|menu:channel|placeholder',
"Select New Channel"
)),
channel_types=[discord.ChannelType.text, discord.ChannelType.voice],
min_values=1, max_values=1
)
async def repost_widget(selection: discord.Interaction, selected: ChannelSelect):
channel = selected.values[0].resolve() if selected.values else None
if channel is None:
await selection.response.defer()
else:
# Valid channel selected, do the repost
await selection.response.defer(thinking=True, ephemeral=True)
try:
await self.menu.repost_to(channel)
except discord.Forbidden:
title = t(_p(
'ui:menu_editor|button:repost|widget:repost|error:perms|title',
"Insufficient Permissions!"
))
desc = t(_p(
'ui:menu_editor|button:repost|eidget:repost|error:perms|desc',
"I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission in this channel."
))
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=title,
description=desc
)
await selection.edit_original_response(embed=embed)
except discord.HTTPException:
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'ui:menu_editor|button:repost|widget:repost|error:post_failed',
"An error ocurred while posting to {channel}. Do I have sufficient permissions?"
)).format(channel=channel.mention)
)
await selection.edit_original_response(embed=error)
else:
try:
await press.delete_original_response()
except discord.HTTPException:
pass
success_title = t(_p(
'ui:menu_editor|button:repost|widget:repost|success|title',
"Role Menu Moved"
))
desc_lines = []
desc_lines.append(
t(_p(
'ui:menu_editor|button:repost|widget:repost|success|desc:general',
"The role menu `{name}` is now available at {message_link}."
)).format(
name=self.menu.data.name,
message_link=self.menu.message.jump_url,
)
)
if self.menu.data.menutype is MenuType.REACTION:
try:
await self.menu.update_reactons()
except SafeCancellation as e:
desc_lines.append(e.msg)
else:
t(_p(
'ui:menu_editor|button:repost|widget:repost|success|desc:reactions',
"Please check the message reactions are correct."
))
await selection.edit_original_response(
embed=discord.Embed(
title=success_title,
description='\n'.join(desc_lines),
colour=discord.Colour.brand_green(),
)
)
# Create the selection embed
title = t(_p(
'ui:menu_editor|button:repost|widget:repost|title',
"Repost Role Menu"
))
desc = t(_p(
'ui:menu_editor|button:repost|widget:repost|description',
"Please select the channel to which you want to resend this menu."
))
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title, description=desc
)
# Send as response with the repost widget attached
await press.response.send_message(embed=embed, view=AsComponents(repost_widget))
async def repost_button_refresh(self):
t = self.bot.translator.t
button = self.repost_button
if self.menu.message is not None:
button.label = t(_p(
'ui:menu_editor|button:repost|label:repost',
"Repost"
))
else:
button.label = t(_p(
'ui:menu_editor|button:repost|label:post',
"Post"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
# TODO: Link to actual message
title = t(_p(
'ui:menu_editor|embed|title',
"'{name}' Role Menu Editor"
"Role Menu Editor"
)).format(name=self.menu.config.name.value)
table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid)
jump = self.menu.jump_link
if jump:
jump_text = t(_p(
'ui:menu_editor|embed|description|jump_text:attached',
"Members may use this menu from {jump_url}"
)).format(jump_url=jump)
else:
jump_text = t(_p(
'ui:menu_editor|embed|description|jump_text:unattached',
"This menu is not currently active!\n"
"Make it available by clicking `Post` below."
))
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title,
description=table
description=jump_text + '\n' + table
)
# Tip field
embed.add_field(
inline=False,
name=t(_p(
'ui:menu_editor|embed|field:tips|name',
"Command Tips"
)),
value=t(_p(
'ui:menu_editor|embed|field:tips|value',
"Use the following commands for faster menu setup.\n"
"{menuedit} to edit the above menu options.\n"
"{addrole} to add new roles (recommended for roles with emojis).\n"
"{editrole} to edit role options."
)).format(
menuedit=self.bot.core.mention_cmd('rolemenu editmenu'),
addrole=self.bot.core.mention_cmd('rolemenu addrole'),
editrole=self.bot.core.mention_cmd('rolemenu editrole'),
)
)
# Compute and add the pages
for mrole in self.page_block:
name = f"{mrole.config.label.formatted}"
prop_map = {
mrole.config.emoji.display_name: mrole.config.emoji.formatted,
mrole.config.price.display_name: mrole.config.price.formatted,
mrole.config.duration.display_name: mrole.config.duration.formatted,
mrole.config.description.display_name: mrole.config.description.formatted,
}
prop_table = '\n'.join(tabulate(*prop_map.items()))
value = f"{mrole.config.role.formatted}\n{prop_table}"
embed.add_field(name=name, value=value, inline=True)
return MessageArgs(embed=embed)
async def _handle_invalid_emoji(self, error: discord.HTTPException):
t = self.bot.translator.t
text = error.text
splits = text.split('.')
i = splits.index('emoji')
role_index = int(splits[i-1])
mrole = self.menu.roles[role_index]
error = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'ui:menu_editor|error:invald_emoji|title',
"Invalid emoji encountered."
)),
description=t(_p(
'ui:menu_editor|error:invalid_emoji|desc',
"The emoji `{emoji}` for menu role `{label}` no longer exists, unsetting."
)).format(emoji=mrole.config.emoji.data, label=mrole.config.label.data)
)
await mrole.data.update(emoji=None)
await self.channel.send(embed=error)
async def _redraw(self, args):
try:
await super()._redraw(args)
except discord.HTTPException as e:
if e.code == 50035 and 'Invalid emoji' in e.text:
await self._handle_invalid_emoji(e)
await self.refresh()
await self.update_preview()
await self.menu.update_message()
else:
raise e
async def draw(self, *args, **kwargs):
try:
await super().draw(*args, **kwargs)
except discord.HTTPException as e:
if e.code == 50035 and 'Invalid emoji' in e.text:
await self._handle_invalid_emoji(e)
await self.draw(*args, **kwargs)
await self.menu.update_message()
else:
raise e
async def refresh_layout(self):
to_refresh = (
self.options_button_refresh(),
@@ -709,15 +1091,20 @@ class MenuEditor(MessageUI):
self.style_menu_refresh(),
self.template_menu_refresh(),
self.preview_button_refresh(),
self.delete_button_refresh(),
self.edit_msg_button_refresh(),
self.repost_button_refresh(),
)
await asyncio.gather(*to_refresh)
line_1 = (
self.options_button, self.modify_roles_button, self.style_button,
)
line_last = (
self.preview_button,
self.options_button, self.modify_roles_button, self.style_button, self.delete_button, self.quit_button
)
line_1 = (
self.preview_button, self.edit_msg_button, self.repost_button,
)
if self.page_count > 1:
line_1 = (self.prev_page_button, *line_last, self.next_page_button)
if self.mode is EditorMode.OPTIONS:
self.set_layout(
line_1,
@@ -742,4 +1129,10 @@ class MenuEditor(MessageUI):
)
async def reload(self):
...
mroles = self.menu.roles
page_size = 6
blocks = [mroles[i:i+page_size] for i in range(0, len(mroles), page_size)] or [[]]
self.page_count = len(blocks)
self.pagen = self.pagen % self.page_count
self.page_block = blocks[self.pagen]
await self.menu.fetch_message()

View File

@@ -0,0 +1,248 @@
import asyncio
from typing import Optional, TYPE_CHECKING
from collections import defaultdict
import discord
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.select import select, Select, SelectOption
from meta import LionBot, conf
from utils.lib import MessageArgs
from utils.ui import MessageUI
from .. import babel
from ..rolemenu import RoleMenu
from .menueditor import MenuEditor
if TYPE_CHECKING:
from ..cog import RoleMenuCog
_p = babel._p
class MenuList(MessageUI):
blocklen = 20
def __init__(self, bot: LionBot, guild: discord.Guild, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.guild = guild
self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog')
self.pagen = 0
self.menus = []
self.menu_blocks = [[]]
self._menu_editor: Optional[MenuEditor] = None
@property
def page(self):
self.pagen %= self.page_count
return self.menu_blocks[self.pagen]
@property
def page_count(self):
return len(self.menu_blocks)
# ----- UI API -----
# ----- Components -----
# Quit Button
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Close the UI. This should also close all children.
"""
await press.response.defer(thinking=False)
await self.quit()
# Page Buttons
@button(emoji=conf.emojis.forward)
async def next_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen += 1
await self.refresh()
@button(emoji=conf.emojis.backward)
async def prev_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen -= 1
await self.refresh()
@button(emoji=conf.emojis.refresh)
async def refresh_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
await self.refresh()
# Menu selector
@select(cls=Select, placeholder="EDITMENU_MENU_PLACEHOLDER", min_values=0, max_values=1)
async def editmenu_menu(self, selection: discord.Interaction, selected: Select):
"""
Opens the menu editor for the selected menu.
Replaces the existing editor, if it exists.
"""
if selected.values:
await selection.response.defer(thinking=True, ephemeral=True)
if self._menu_editor is not None and not self._menu_editor.is_finished():
await self._menu_editor.quit()
menuid = int(selected.values[0])
menu = await RoleMenu.fetch(self.bot, menuid)
editor = MenuEditor(self.bot, menu, callerid=self._callerid)
self._menu_editor = editor
self._slaves.append(editor)
await editor.run(selection)
else:
await selection.response.defer()
async def editmenu_menu_refresh(self):
t = self.bot.translator.t
menu = self.editmenu_menu
menu.placeholder = t(_p(
'ui:menu_list|menu:editmenu|placeholder',
"Select Menu to Edit"
))
menus = self.page
if menus:
menu.options = [
self._format_menu_option(m) for m in menus
]
menu.disabled = False
else:
menu.options = [
SelectOption(label='DUMMY')
]
menu.disabled = True
# ----- UI Flow -----
def _format_menu_line(self, menu: RoleMenu) -> str:
"""
Format a provided RoleMenu into a pretty display line.
"""
t = self.bot.translator.t
jump_link = menu.jump_link
if jump_link is not None:
line = t(_p(
'ui:menu_list|menu_line:attached',
"[`{name}`]({jump_url}) with `{count}` roles."
)).format(
name=menu.config.name.value,
jump_url=jump_link,
count=len(menu.roles)
)
else:
line = t(_p(
'ui:menu_list|menu_line:unattached',
"`{name}` with `{count}` roles."
)).format(
name=menu.config.name.value,
count=len(menu.roles)
)
return line
def _format_menu_option(self, menu: RoleMenu) -> SelectOption:
"""
Format a provided RoleMenu into a SelectOption.
"""
option = SelectOption(
value=str(menu.data.menuid),
label=menu.config.name.value[:100],
)
return option
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
menus = self.page
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'ui:menu_list|embed|title',
"Role Menus in {guild}"
)).format(guild=self.guild.name)
)
if not menus:
# Empty page message
# Add tips to create menus
tips_name = t(_p(
'ui:menu_list|embed|field:tips|name',
"Tips"
))
tips_value = t(_p(
'ui:menu_list|embed|field:tips|value',
"Right click an existing message or use the `newmenu` command to create a new menu."
))
embed.add_field(name=tips_name, value=tips_value)
# TODO: Guide image
else:
# Partition menus by channel, without breaking the order
channel_lines = defaultdict(list)
for menu in menus:
channel_lines[menu.data.channelid].append(self._format_menu_line(menu))
for channelid, lines in channel_lines.items():
name = f"<#{channelid}>" if channelid else t(_p(
'ui:menu_list|embed|field:unattached|name',
"Unattached Menus"
))
value = '\n'.join(lines)
# Precaution in case all the menu names are really long
value = value[:1024]
embed.add_field(
name=name, value=value, inline=False
)
embed.set_footer(
text=t(_p(
'ui:menu_list|embed|footer:text',
"Click a menu name to jump to the message."
))
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
refresh_tasks = (
self.editmenu_menu_refresh(),
)
await asyncio.gather(*refresh_tasks)
if len(self.menu_blocks) > 1:
self.prev_page_button.disabled = False
self.next_page_button.disabled = False
else:
self.prev_page_button.disabled = True
self.next_page_button.disabled = True
self.set_layout(
(self.prev_page_button, self.refresh_button, self.next_page_button, self.quit_button,),
(self.editmenu_menu,),
)
def _sort_key(self, menu_data):
message_exists = int(bool(menu_data.messageid))
channel = self.guild.get_channel(menu_data.channelid) if menu_data.channelid else None
channel_position = channel.position if channel is not None else 0
# Unattached menus will be ordered by their creation id
messageid = menu_data.messageid or menu_data.menuid
return (message_exists, channel_position, messageid)
async def reload(self):
# Fetch menu data for this guild
menu_data = await self.cog.data.RoleMenu.fetch_where(guildid=self.guild.id)
# Order menu data by (message_exists, channel_position, messageid)
sorted_menu_data = sorted(menu_data, key=self._sort_key)
# Fetch associated menus, load into self.menus
menus = []
for data in sorted_menu_data:
menu = await RoleMenu.fetch(self.bot, data.menuid)
menus.append(menu)
self.menus = menus
self.menu_blocks = [menus[i:i+self.blocklen] for i in range(0, len(menus), self.blocklen)] or [[]]

View File

@@ -105,6 +105,116 @@ class StringSetting(InteractiveSetting[ParentID, str, str]):
return None
class EmojiSetting(InteractiveSetting[ParentID, str, str]):
"""
Setting type representing a stored emoji.
The emoji is stored in a single string field, and at no time is guaranteed to be a valid emoji.
"""
_accepts = _p('settype:emoji|accepts', "Paste a builtin emoji, custom emoji, or emoji id.")
@property
def input_formatted(self) -> str:
"""
Return the current data string.
"""
if self._data is not None:
return str(self._data)
else:
return ""
@classmethod
def _data_from_value(cls, parent_id: ParentID, value, **kwargs):
"""
Return the provided value string as the data string.
"""
return value
@classmethod
def _data_to_value(cls, id, data, **kwargs):
"""
Return the provided data string as the value string.
"""
return data
@classmethod
async def _parse_string(cls, parent_id, string: str, **kwargs):
"""
Parse the given user entered emoji string.
Accepts unicode (builtin) emojis, custom emojis, and custom emoji ids.
"""
t = ctx_translator.get().t
provided = string
string = string.strip(' :<>')
if string.startswith('a:'):
string = string[2:]
if not string or string.lower() == 'none':
emojistr = None
elif string.isdigit():
# Assume emoji id
emojistr = f"<a:unknown:{string}>"
elif ':' in string:
# Assume custom emoji
emojistr = provided.strip()
elif string.isascii():
# Probably not an emoji
raise UserInputError(
t(_p(
'settype:emoji|error:parse',
"Could not parse `{provided}` as a Discord emoji. "
"Supported formats are builtin emojis (e.g. `{builtin}`), "
"custom emojis (e.g. {custom}), "
"or custom emoji ids (e.g. `{custom_id}`)."
)).format(
provided=provided,
builtin="🤔",
custom="*`<`*`CuteLeo:942499177135480942`*`>`*",
custom_id="942499177135480942",
)
)
else:
# We don't have a good way of testing for emoji unicode
# So just assume anything with unicode is an emoji.
emojistr = string
return emojistr
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
"""
Optionally (see `_quote`) wrap the data string in backticks.
"""
if data:
return data
else:
return None
@property
def as_partial(self) -> Optional[discord.PartialEmoji]:
return self._parse_emoji(self.data)
@staticmethod
def _parse_emoji(emojistr: str):
"""
Converts a provided string into a PartialEmoji.
Deos not validate the emoji string.
"""
if not emojistr:
return None
elif ":" in emojistr:
emojistr = emojistr.strip('<>')
splits = emojistr.split(":")
if len(splits) == 3:
animated, name, id = splits
animated = bool(animated)
return discord.PartialEmoji(name=name, animated=animated, id=int(id))
else:
return discord.PartialEmoji(name=emojistr)
CT = TypeVar('CT', 'GuildChannel', 'discord.Object', 'discord.Thread')
MCT = TypeVar('MCT', discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.Object)
@@ -558,7 +668,7 @@ class IntegerSetting(InteractiveSetting[ParentID, int, int]):
return f"`{data}`"
class EmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]):
class PartialEmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]):
"""
Setting type mixin describing an Emoji string.
@@ -1140,15 +1250,6 @@ class DurationSetting(InteractiveSetting[ParentID, int, int]):
return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days))
class MessageSetting(StringSetting):
"""
Typed Setting ABC representing a message sent to Discord.
Placeholder implemented as a StringSetting until Context is built.
"""
...
class ListSetting:
"""
Mixin to implement a setting type representing a list of existing settings.

View File

@@ -150,7 +150,7 @@ class TextTrackerCog(LionCog):
)
# Batch-fetch lguilds
lguilds = await self.bot.core.lions.fetch_guilds(*(session.guildid for session in batch))
lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch})
# Build data
rows = []

View File

@@ -6,6 +6,7 @@ import re
from contextvars import Context
import discord
from discord.partial_emoji import _EmojiTag
from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage
from discord.ui import View
@@ -770,3 +771,20 @@ def replace_multiple(format_string, mapping):
pattern = '|'.join(f"({key})" for key in keys)
string = re.sub(pattern, lambda match: str(mapping[keys[match.lastindex - 1]]), format_string)
return string
def emojikey(emoji: discord.Emoji | discord.PartialEmoji | str):
"""
Produces a distinguishing key for an Emoji or PartialEmoji.
Equality checks using this key should act as expected.
"""
if isinstance(emoji, _EmojiTag):
if emoji.id:
key = str(emoji.id)
else:
key = str(emoji.name)
else:
key = str(emoji)
return key

View File

@@ -47,7 +47,7 @@ class TaskMonitor(Generic[Taskid]):
Similar to `schedule_tasks`, but wipe and reset the tasklist.
"""
self._taskmap = {tid: time for tid, time in tasks}
self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * tid * self._taskmap[tid])
self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid]))
self._wakeup.set()
def schedule_tasks(self, *tasks: tuple[Taskid, int]) -> None:
@@ -59,7 +59,7 @@ class TaskMonitor(Generic[Taskid]):
we build an entirely new list, and always wake up the loop.
"""
self._taskmap |= {tid: time for tid, time in tasks}
self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid])
self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid]))
self._wakeup.set()
def schedule_task(self, taskid: Taskid, timestamp: int) -> None:

View File

@@ -248,6 +248,13 @@ class MessageUI(LeoUI):
# Refresh lock, to avoid cache collisions on refresh
self._refresh_lock = asyncio.Lock()
@property
def channel(self):
if self._original is not None:
return self._original.channel
else:
return self._message.channel
# ----- UI API -----
async def run(self, interaction: discord.Interaction, **kwargs):
"""
@@ -366,6 +373,15 @@ class MessageUI(LeoUI):
args = await self.make_message()
self._message = await channel.send(**args.send_args, view=self)
async def _redraw(self, args):
if self._original and not self._original.is_expired():
await self._original.edit_original_response(**args.edit_args, view=self)
elif self._message:
await self._message.edit(**args.edit_args, view=self)
else:
# Interaction expired or already closed. Quietly cleanup.
await self.close()
async def redraw(self, thinking: Optional[discord.Interaction] = None):
"""
Update the output message for this UI.
@@ -379,13 +395,7 @@ class MessageUI(LeoUI):
asyncio.create_task(thinking.delete_original_response())
try:
if self._original and not self._original.is_expired():
await self._original.edit_original_response(**args.edit_args, view=self)
elif self._message:
await self._message.edit(**args.edit_args, view=self)
else:
# Interaction expired or already closed. Quietly cleanup.
await self.close()
await self._redraw(args)
except discord.HTTPException:
# Unknown communication erorr, nothing we can reliably do. Exit quietly.
await self.close()

View File

@@ -167,7 +167,10 @@ class ModalRetryUI(LeoUI):
async def respond_to(self, interaction):
self._interaction = interaction
await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self)
if interaction.response.is_done():
await interaction.followup.send(embed=self.embed, ephemeral=True, view=self)
else:
await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self)
@button(label="Retry")
async def retry_button(self, interaction, butt):