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 -- 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'); INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
COMMIT; COMMIT;

View File

@@ -24,8 +24,12 @@ CREATE TABLE role_menus(
obtainable INTEGER, obtainable INTEGER,
menutype RoleMenuType NOT NULL, menutype RoleMenuType NOT NULL,
templateid INTEGER, 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( CREATE TABLE role_menu_roles(
@@ -39,6 +43,8 @@ CREATE TABLE role_menu_roles(
duration INTEGER, duration INTEGER,
rawreply TEXT 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( CREATE TABLE role_menu_history(
@@ -49,5 +55,35 @@ CREATE TABLE role_menu_history(
obtained_at TIMESTAMPTZ NOT NULL, obtained_at TIMESTAMPTZ NOT NULL,
transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
expires_at TIMESTAMPTZ, 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 meta import LionBot
from utils.lib import Timezoned from utils.lib import Timezoned
from settings.groups import ModelConfig, SettingDotDict from settings.groups import ModelConfig, SettingDotDict
from babel.translator import SOURCE_LOCALE
from .data import CoreData from .data import CoreData
from .lion_user import LionUser from .lion_user import LionUser
@@ -63,6 +64,21 @@ class LionMember(Timezoned):
guild_timezone = self.lguild.config.timezone guild_timezone = self.lguild.config.timezone
return user_timezone.value if user_timezone._data is not None else guild_timezone.value 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): async def touch_discord_model(self, member: discord.Member):
""" """
Update saved Discord model attributes for this member. Update saved Discord model attributes for this member.
@@ -82,3 +98,15 @@ class LionMember(Timezoned):
except discord.HTTPException: except discord.HTTPException:
pass pass
return member 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. 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 import conf
from meta.errors import UserInputError from meta.errors import UserInputError
from constants import MAX_COINS from constants import MAX_COINS
@@ -62,3 +69,202 @@ class CoinSetting(IntegerSetting):
"{coin}**{amount}**" "{coin}**{amount}**"
)).format(coin=conf.emojis.coin, amount=data) )).format(coin=conf.emojis.coin, amount=data)
return formatted 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( logger.debug(
f"Executed reminder <rid: {reminder.reminderid}>." f"Executed reminder <rid: {reminder.reminderid}>."
) )
except discord.HTTPException: except discord.HTTPException as e:
await reminder.update(failed=True) await reminder.update(failed=True)
logger.debug( logger.debug(
f"Reminder <rid: {reminder.reminderid}> could not be sent.", f"Reminder <rid: {reminder.reminderid}> could not be sent: {e.text}",
exc_info=True
) )
except Exception: except Exception:
await reminder.update(failed=True) 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 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 from data.columns import Integer, Timestamp, String, Bool
@@ -35,6 +35,9 @@ class RoleMenuData(Registry):
templateid = Integer() templateid = Integer()
rawmessage = String() rawmessage = String()
default_price = Integer()
event_log = Bool()
class RoleMenuRole(RowModel): class RoleMenuRole(RowModel):
_tablename_ = 'role_menu_roles' _tablename_ = 'role_menu_roles'
_cache_ = {} _cache_ = {}
@@ -66,4 +69,17 @@ class RoleMenuData(Registry):
obtained_at = Timestamp() obtained_at = Timestamp()
transactionid = Integer() transactionid = Integer()
expires_at = Timestamp() 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 RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting
) )
from core.setting_types import MessageSetting
from .data import RoleMenuData from .data import RoleMenuData
from . import babel from . import babel
_p = babel._p _p = babel._p
# TODO: Write some custom accepts fields
# TODO: The *name* might be an important setting!
class RoleMenuConfig(ModelConfig): class RoleMenuConfig(ModelConfig):
settings = SettingDotDict() settings = SettingDotDict()
_model_settings = set() _model_settings = set()
@@ -45,6 +43,10 @@ class RoleMenuConfig(ModelConfig):
def obtainable(self): def obtainable(self):
return self.get(RoleMenuOptions.Obtainable.setting_id) return self.get(RoleMenuOptions.Obtainable.setting_id)
@property
def rawmessage(self):
return self.get(RoleMenuOptions.Message.setting_id)
class RoleMenuOptions(SettingGroup): class RoleMenuOptions(SettingGroup):
@RoleMenuConfig.register_model_setting @RoleMenuConfig.register_model_setting
@@ -56,6 +58,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:name|desc', 'menuset:name|desc',
"Brief name for this role menu." "Brief name for this role menu."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'menuset:name|long_desc', 'menuset:name|long_desc',
"The role menu name is displayed when selecting the menu in commands, " "The role menu name is displayed when selecting the menu in commands, "
@@ -66,6 +69,16 @@ class RoleMenuOptions(SettingGroup):
_model = RoleMenuData.RoleMenu _model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.name.name _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 @RoleMenuConfig.register_model_setting
class Sticky(ModelData, BoolSetting): class Sticky(ModelData, BoolSetting):
setting_id = 'sticky' setting_id = 'sticky'
@@ -75,6 +88,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:sticky|desc', 'menuset:sticky|desc',
"Whether the menu can be used to unequip roles." "Whether the menu can be used to unequip roles."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'menuset:sticky|long_desc', 'menuset:sticky|long_desc',
"When enabled, members will not be able to remove equipped roles by selecting them in this menu. " "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 _model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.sticky.name _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 @RoleMenuConfig.register_model_setting
class Refunds(ModelData, BoolSetting): class Refunds(ModelData, BoolSetting):
setting_id = 'refunds' setting_id = 'refunds'
@@ -95,6 +125,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:refunds|desc', 'menuset:refunds|desc',
"Whether removing a role will refund the purchase price for that role." "Whether removing a role will refund the purchase price for that role."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'menuset:refunds|long_desc', 'menuset:refunds|long_desc',
"When enabled, members who *purchased a role through this role menu* will obtain a full refund " "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 _model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.refunds.name _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 @RoleMenuConfig.register_model_setting
class Obtainable(ModelData, IntegerSetting): class Obtainable(ModelData, IntegerSetting):
setting_id = 'obtainable' setting_id = 'obtainable'
@@ -116,16 +163,37 @@ class RoleMenuOptions(SettingGroup):
'menuset:obtainable|desc', 'menuset:obtainable|desc',
"The maximum number of roles equippable from this menu." "The maximum number of roles equippable from this menu."
) )
_accepts = _desc
_long_desc = _p( _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. " "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 _default = None
_model = RoleMenuData.RoleMenu _model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.obtainable.name _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 @RoleMenuConfig.register_model_setting
class RequiredRole(ModelData, RoleSetting): class RequiredRole(ModelData, RoleSetting):
setting_id = 'required_role' setting_id = 'required_role'
@@ -135,6 +203,7 @@ class RoleMenuOptions(SettingGroup):
'menuset:required_role|desc', 'menuset:required_role|desc',
"Initial role required to use this menu." "Initial role required to use this menu."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'menuset:required_role|long_desc', '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." "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 _model = RoleMenuData.RoleMenu
_column = RoleMenuData.RoleMenu.required_roleid.name _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 RoleMenuData as Data
from .data import MenuType
from .menuoptions import RoleMenuConfig from .menuoptions import RoleMenuConfig
from .roleoptions import RoleMenuRoleConfig 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: class RoleMenuRole:
@@ -12,31 +47,678 @@ class RoleMenuRole:
self.data = data self.data = data
self.config = RoleMenuRoleConfig(data.menuroleid, 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: 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.bot = bot
self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog')
self.data = data self.data = data
self.config = RoleMenuConfig(data.menuid, 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 @property
def message(self): 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 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): async def attach(self):
await self.data.refresh() """
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 roledata = self.bot.get_cog('RoleMenuCog').data.RoleMenuRole
role_rows = await roledata.fetch_where(menuid=self.data.menuid) role_rows = await roledata.fetch_where(menuid=self.data.menuid).order_by('menuroleid')
self.roles = [RoleMenuRole(self.bot, row) for row in role_rows] 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 import ModelData
from settings.groups import SettingGroup, ModelConfig, SettingDotDict from settings.groups import SettingGroup, ModelConfig, SettingDotDict
from settings.setting_types import ( from settings.setting_types import (
RoleSetting, BoolSetting, StringSetting, DurationSetting RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting
) )
from core.setting_types import CoinSetting 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 .data import RoleMenuData
from . import babel from . import babel
@@ -59,26 +64,49 @@ class RoleMenuRoleOptions(SettingGroup):
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.roleid.name _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 @RoleMenuRoleConfig.register_model_setting
class Label(ModelData, StringSetting): class Label(ModelData, StringSetting):
setting_id = 'role' setting_id = 'label'
_display_name = _p('roleset:label', "label") _display_name = _p('roleset:label', "label")
_desc = _p( _desc = _p(
'roleset:label|desc', 'roleset:label|desc',
"A short button label for this role." "A short button label for this role."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'roleset:label|long_desc', 'roleset:label|long_desc',
"A short name for this role, to be displayed in button labels, dropdown titles, and some menu layouts. " "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." "By default uses the Discord role name."
) )
_quote = False
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.label.name _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 @RoleMenuRoleConfig.register_model_setting
class Emoji(ModelData, StringSetting): class Emoji(ModelData, EmojiSetting):
setting_id = 'emoji' setting_id = 'emoji'
_display_name = _p('roleset:emoji', "emoji") _display_name = _p('roleset:emoji', "emoji")
@@ -96,6 +124,56 @@ class RoleMenuRoleOptions(SettingGroup):
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.emoji.name _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 @RoleMenuRoleConfig.register_model_setting
class Description(ModelData, StringSetting): class Description(ModelData, StringSetting):
setting_id = 'description' setting_id = 'description'
@@ -105,15 +183,34 @@ class RoleMenuRoleOptions(SettingGroup):
'roleset:description|desc', 'roleset:description|desc',
"A longer description of this role." "A longer description of this role."
) )
_accepts = _desc
_long_desc = _p( _long_desc = _p(
'roleset:description|long_desc', 'roleset:description|long_desc',
"The description is displayed under the role label in dropdown style menus. " "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." "It may also be used as a substitution key in custom role selection responses."
) )
_quote = False
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.description.name _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 @RoleMenuRoleConfig.register_model_setting
class Price(ModelData, CoinSetting): class Price(ModelData, CoinSetting):
setting_id = 'price' setting_id = 'price'
@@ -127,10 +224,30 @@ class RoleMenuRoleOptions(SettingGroup):
'roleset:price|long_desc', 'roleset:price|long_desc',
"How much the role costs when selected, in LionCoins." "How much the role costs when selected, in LionCoins."
) )
_accepts = _p(
'roleset:price|accepts',
"Amount of coins that the role costs."
)
_default = 0 _default = 0
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.price.name _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 @RoleMenuRoleConfig.register_model_setting
class Duration(ModelData, DurationSetting): class Duration(ModelData, DurationSetting):
setting_id = 'duration' setting_id = 'duration'
@@ -145,5 +262,26 @@ class RoleMenuRoleOptions(SettingGroup):
"Allows creation of 'temporary roles' which expire a given time after being equipped. " "Allows creation of 'temporary roles' which expire a given time after being equipped. "
"Refunds will not be given upon expiry." "Refunds will not be given upon expiry."
) )
_notset_str = _p(
'roleset:duration|notset',
"Forever."
)
_model = RoleMenuData.RoleMenuRole _model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.duration.name _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 = '🔲' DEFAULT_EMOJI = '🔲'
templates = {} templates: dict[int, 'Template'] = {}
class Template: class Template:
@@ -68,7 +68,7 @@ async def simple_template(menu) -> MessageArgs:
duration = menurole.config.duration duration = menurole.config.duration
if emoji.data: if emoji.data:
parts.append(emoji.formatted) parts.append(emoji.data)
parts.append(role.formatted) parts.append(role.formatted)
@@ -114,7 +114,7 @@ async def twocolumn_template(menu) -> MessageArgs:
) )
for block in blocks: for block in blocks:
block_lines = [ 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 for menurole in block
] ]
if block_lines: if block_lines:
@@ -151,7 +151,7 @@ async def threecolumn_template(menu) -> MessageArgs:
) )
for block in blocks: for block in blocks:
block_lines = [ 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 for menurole in block
] ]
if block_lines: if block_lines:
@@ -188,7 +188,7 @@ async def shop_template(menu) -> MessageArgs:
parts.append("|") parts.append("|")
if emoji.data: if emoji.data:
parts.append(emoji.formatted) parts.append(emoji.data)
parts.append(role.formatted) 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 discord.ui.select import select, Select, RoleSelect, ChannelSelect, SelectOption
from meta import LionBot, conf from meta import LionBot, conf
from meta.errors import UserInputError from meta.errors import UserInputError, ResponseTimedOut, SafeCancellation
from utils.lib import utc_now, MessageArgs, error_embed from utils.lib import utc_now, MessageArgs, error_embed, tabulate
from utils.ui import MessageUI, ConfigEditor, FastModal, error_handler_for, ModalRetryUI, MsgEditor from utils.ui import (
MessageUI, ConfigEditor, FastModal, error_handler_for,
ModalRetryUI, MsgEditor, Confirm, HookedItem, AsComponents,
)
from babel.translator import ctx_locale from babel.translator import ctx_locale
from wards import equippable_role from wards import equippable_role
@@ -32,6 +35,10 @@ class RoleEditorInput(FastModal):
await ModalRetryUI(self, error.msg).respond_to(interaction) await ModalRetryUI(self, error.msg).respond_to(interaction)
class AChannelSelect(HookedItem, ChannelSelect):
...
class EditorMode(Enum): class EditorMode(Enum):
OPTIONS = 0 OPTIONS = 0
ROLES = 1 ROLES = 1
@@ -39,6 +46,8 @@ class EditorMode(Enum):
class MenuEditor(MessageUI): class MenuEditor(MessageUI):
_listening = {} # (channelid, callerid) -> active MenuEditor
def _init_children(self): def _init_children(self):
# HACK to stop ViewWeights complaining that this UI has too many children # HACK to stop ViewWeights complaining that this UI has too many children
# Children will be correctly initialised after parent init. # Children will be correctly initialised after parent init.
@@ -51,40 +60,76 @@ class MenuEditor(MessageUI):
self.bot = bot self.bot = bot
self.menu = menu self.menu = menu
self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data
self.listen_key = None
# UI State # UI State
self.mode: EditorMode = EditorMode.ROLES self.mode: EditorMode = EditorMode.ROLES
self.page_count: int = 1
self.pagen: int = 0 self.pagen: int = 0
self.page_block: list[RoleMenuRole] = []
self._preview: Optional[discord.Interaction] = None self._preview: Optional[discord.Interaction] = None
# ----- UI API ----- # ----- UI API -----
async def dispatch_update(self): async def update_preview(self):
""" """
Broadcast that the menu has changed. Update the preview message if it exists.
This updates the preview, and tells the menu itself to update any linked messages.
""" """
await self.menu.reload()
if self._preview is not None: if self._preview is not None:
args = await self._preview_args() args = await self.menu.make_args()
view = await self.menu.make_view()
try: try:
await self._preview.edit_original_response(**args.edit_args) await self._preview.edit_original_response(**args.edit_args, view=view)
except discord.NotFound: except discord.NotFound:
self._preview = None 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): async def cleanup(self):
if (tid := self.menu.data.templateid) is not None: self._listening.pop(self.listen_key, None)
# Apply template await super().cleanup()
template = templates[tid]
args = await template.render_menu(self.menu) async def run(self, interaction: discord.Interaction, **kwargs):
else: self.listen_key = (interaction.channel.id, interaction.user.id, self.menu.data.menuid)
raw = self.menu.data.rawmessage existing = self._listening.get(self.listen_key, None)
data = json.loads(raw) if existing:
args = MessageArgs( await existing.quit()
content=data.get('content', ''), self._listening[self.listen_key] = self
embed=discord.Embed.from_dict(data['embed']) await super().run(interaction, **kwargs)
)
return args 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 ----- # ----- Components -----
# -- Options Components -- # -- Options Components --
@@ -154,8 +199,6 @@ class MenuEditor(MessageUI):
# Write settings # Write settings
for instance in modified: for instance in modified:
await instance.write() await instance.write()
# Propagate an update
await self.dispatch_update()
# Refresh the UI # Refresh the UI
await self.refresh(thinking=interaction) await self.refresh(thinking=interaction)
else: else:
@@ -182,7 +225,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.sticky instance = self.menu.config.sticky
instance.value = not instance.value instance.value = not instance.value
await instance.write() await instance.write()
await self.dispatch_update()
await self.refresh(thinking=press) await self.refresh(thinking=press)
async def sticky_button_refresh(self): async def sticky_button_refresh(self):
@@ -207,7 +249,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.refunds instance = self.menu.config.refunds
instance.value = not instance.value instance.value = not instance.value
await instance.write() await instance.write()
await self.dispatch_update()
await self.refresh(thinking=press) await self.refresh(thinking=press)
async def refunds_button_refresh(self): async def refunds_button_refresh(self):
@@ -215,7 +256,7 @@ class MenuEditor(MessageUI):
button = self.refunds_button button = self.refunds_button
button.label = t(_p( button.label = t(_p(
'ui:menu_editor|button:refunds|label', 'ui:menu_editor|button:refunds|label',
"Refunds" "Toggle Refunds"
)) ))
if self.menu.config.refunds.value: if self.menu.config.refunds.value:
button.style = ButtonStyle.blurple button.style = ButtonStyle.blurple
@@ -238,7 +279,6 @@ class MenuEditor(MessageUI):
instance = self.menu.config.required_role instance = self.menu.config.required_role
instance.data = new_data instance.data = new_data
await instance.write() await instance.write()
await self.dispatch_update()
await self.refresh(thinking=selection) await self.refresh(thinking=selection)
async def reqroles_menu_refresh(self): async def reqroles_menu_refresh(self):
@@ -300,6 +340,7 @@ class MenuEditor(MessageUI):
@modal.submit_callback() @modal.submit_callback()
async def save_options(interaction: discord.Interaction): async def save_options(interaction: discord.Interaction):
await interaction.response.defer(thinking=True, ephemeral=True)
modified = [] modified = []
for instance, field, original in zip(instances, fields, originals): for instance, field, original in zip(instances, fields, originals):
if field.value != original: if field.value != original:
@@ -308,25 +349,32 @@ class MenuEditor(MessageUI):
if not userstr: if not userstr:
new_data = None new_data = None
else: 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 instance.data = new_data
modified.append(instance) modified.append(instance)
if modified: if modified:
# All fields have been parsed, it is safe to respond
await interaction.response.defer(thinking=True, ephemeral=True)
# Write settings # Write settings
for instance in modified: for instance in modified:
await instance.write() await instance.write()
# Propagate an update
await self.dispatch_update()
# Refresh the UI # Refresh the UI
await self.refresh(thinking=interaction) 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: else:
# Nothing was modified, quietly accept await interaction.delete_original_response()
await interaction.response.defer(thinking=False)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
await self.dispatch_update()
# Add Roles Menu # Add Roles Menu
@select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25) @select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25)
@@ -369,21 +417,35 @@ class MenuEditor(MessageUI):
))) )))
# Create roles # 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( rows = await self.data.RoleMenuRole.table.insert_many(
('menuid', 'roleid', 'label'), ('menuid', 'roleid', 'label', 'emoji'),
*((self.menu.data.menuid, role.id, role.name[:100]) for role in to_create.values()) *(
(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) ).with_adapter(self.data.RoleMenuRole._make_rows)
mroles = [RoleMenuRole(self.bot, row) for row in rows] mroles = [RoleMenuRole(self.bot, row) for row in rows]
single = single if single is not None else mroles[0] single = single if single is not None else mroles[0]
await self.dispatch_update()
if len(roles) == 1: if len(roles) == 1:
await self._edit_menu_role(selection, single) await self._edit_menu_role(selection, single)
await self.refresh()
else: else:
await selection.response.defer() 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): async def add_roles_menu_refresh(self):
t = self.bot.translator.t t = self.bot.translator.t
@@ -395,6 +457,7 @@ class MenuEditor(MessageUI):
def _role_option(self, menurole: RoleMenuRole): def _role_option(self, menurole: RoleMenuRole):
return SelectOption( return SelectOption(
emoji=menurole.config.emoji.data or None,
label=menurole.config.label.value, label=menurole.config.label.value,
value=str(menurole.data.menuroleid), value=str(menurole.data.menuroleid),
description=menurole.config.description.value, description=menurole.config.description.value,
@@ -435,7 +498,11 @@ class MenuEditor(MessageUI):
if menuroleids: if menuroleids:
await selection.response.defer(thinking=True, ephemeral=True) await selection.response.defer(thinking=True, ephemeral=True)
await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids) 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: else:
await selection.response.defer(thinking=False) await selection.response.defer(thinking=False)
@@ -468,7 +535,7 @@ class MenuEditor(MessageUI):
raise UserInputError( raise UserInputError(
t(_p( t(_p(
'ui:menu_editor|button:style|error:non-managed', '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 Select one of Reaction Roles / Dropdown / Button
""" """
t = self.bot.translator.t t = self.bot.translator.t
value = int(selected.values[0]) value = selected.values[0]
menutype = MenuType(value) menutype = MenuType[value]
if menutype is not self.menu.data.menutype: if menutype is not self.menu.data.menutype:
# A change is requested # A change is requested
if menutype is MenuType.REACTION: if menutype is MenuType.REACTION:
@@ -521,8 +588,9 @@ class MenuEditor(MessageUI):
) )
await selection.response.defer(thinking=True, ephemeral=True) await selection.response.defer(thinking=True, ephemeral=True)
await self.menu.data.update(menutype=menutype) await self.menu.data.update(menutype=menutype)
await self.dispatch_update()
await self.refresh(thinking=selection) await self.refresh(thinking=selection)
await self.update_preview()
await self.menu.update_message()
else: else:
await selection.response.defer() await selection.response.defer()
@@ -540,7 +608,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:reaction|desc', 'ui:menu_editor|menu:style|option:reaction|desc',
"Roles are represented compactly as clickable reactions on a message." "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) default=(self.menu.data.menutype is MenuType.REACTION)
), ),
SelectOption( SelectOption(
@@ -549,7 +617,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:button|desc', 'ui:menu_editor|menu:style|option:button|desc',
"Roles are represented in 5 rows of 5 buttons, each with an emoji and label." "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) default=(self.menu.data.menutype is MenuType.BUTTON)
), ),
SelectOption( SelectOption(
@@ -558,7 +626,7 @@ class MenuEditor(MessageUI):
'ui:menu_editor|menu:style|option:dropdown|desc', 'ui:menu_editor|menu:style|option:dropdown|desc',
"Roles are selectable from a dropdown menu below the message." "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) default=(self.menu.data.menutype is MenuType.DROPDOWN)
) )
] ]
@@ -566,10 +634,12 @@ class MenuEditor(MessageUI):
async def _editor_callback(self, new_data): async def _editor_callback(self, new_data):
raws = json.dumps(new_data) raws = json.dumps(new_data)
await self.menu.data.update(rawmessage=raws) 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): async def _message_editor(self, interaction: discord.Interaction):
# Spawn the message editor with the current rawmessage data. # Spawn the message editor with the current rawmessage data.
# If the rawmessage data is empty, use the current template instead.
editor = MsgEditor( editor = MsgEditor(
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
) )
@@ -608,12 +678,14 @@ class MenuEditor(MessageUI):
# Spawn editor # Spawn editor
await self._message_editor(selection) await self._message_editor(selection)
await self.dispatch_update()
await self.refresh() await self.refresh()
await self.update_preview()
await self.menu.update_message()
else: else:
await self.menu.data.update(templateid=templateid) await self.menu.data.update(templateid=templateid)
await self.dispatch_update()
await self.refresh(thinking=selection) await self.refresh(thinking=selection)
await self.update_preview()
await self.menu.update_message()
else: else:
await selection.response.defer() await selection.response.defer()
@@ -646,24 +718,122 @@ class MenuEditor(MessageUI):
# -- Common Components -- # -- Common Components --
# Delete Menu Button # 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 # 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 # Preview Button
@button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) @button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def preview_button(self, press: discord.Interaction, pressed: Button): async def preview_button(self, press: discord.Interaction, pressed: Button):
""" """
Display or update the preview message. 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: if self._preview is not None:
try: try:
await self._preview.delete_original_response() await self._preview.delete_original_response()
except discord.HTTPException: except discord.HTTPException:
pass pass
self._preview = None 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 self._preview = press
async def preview_button_refresh(self): async def preview_button_refresh(self):
@@ -675,25 +845,237 @@ class MenuEditor(MessageUI):
)) ))
# Repost Menu Button # 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 ----- # ----- UI Flow -----
async def make_message(self) -> MessageArgs: async def make_message(self) -> MessageArgs:
t = self.bot.translator.t t = self.bot.translator.t
# TODO: Link to actual message
title = t(_p( title = t(_p(
'ui:menu_editor|embed|title', 'ui:menu_editor|embed|title',
"'{name}' Role Menu Editor" "Role Menu Editor"
)).format(name=self.menu.config.name.value) )).format(name=self.menu.config.name.value)
table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid) 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( embed = discord.Embed(
colour=discord.Colour.orange(), colour=discord.Colour.orange(),
title=title, 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) 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): async def refresh_layout(self):
to_refresh = ( to_refresh = (
self.options_button_refresh(), self.options_button_refresh(),
@@ -709,15 +1091,20 @@ class MenuEditor(MessageUI):
self.style_menu_refresh(), self.style_menu_refresh(),
self.template_menu_refresh(), self.template_menu_refresh(),
self.preview_button_refresh(), self.preview_button_refresh(),
self.delete_button_refresh(),
self.edit_msg_button_refresh(),
self.repost_button_refresh(),
) )
await asyncio.gather(*to_refresh) await asyncio.gather(*to_refresh)
line_1 = (
self.options_button, self.modify_roles_button, self.style_button,
)
line_last = ( 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: if self.mode is EditorMode.OPTIONS:
self.set_layout( self.set_layout(
line_1, line_1,
@@ -742,4 +1129,10 @@ class MenuEditor(MessageUI):
) )
async def reload(self): 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 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') CT = TypeVar('CT', 'GuildChannel', 'discord.Object', 'discord.Thread')
MCT = TypeVar('MCT', discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.Object) MCT = TypeVar('MCT', discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.Object)
@@ -558,7 +668,7 @@ class IntegerSetting(InteractiveSetting[ParentID, int, int]):
return f"`{data}`" return f"`{data}`"
class EmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]): class PartialEmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]):
""" """
Setting type mixin describing an Emoji string. 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)) 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: class ListSetting:
""" """
Mixin to implement a setting type representing a list of existing settings. Mixin to implement a setting type representing a list of existing settings.

View File

@@ -150,7 +150,7 @@ class TextTrackerCog(LionCog):
) )
# Batch-fetch lguilds # 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 # Build data
rows = [] rows = []

View File

@@ -6,6 +6,7 @@ import re
from contextvars import Context from contextvars import Context
import discord import discord
from discord.partial_emoji import _EmojiTag
from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage
from discord.ui import View from discord.ui import View
@@ -770,3 +771,20 @@ def replace_multiple(format_string, mapping):
pattern = '|'.join(f"({key})" for key in keys) pattern = '|'.join(f"({key})" for key in keys)
string = re.sub(pattern, lambda match: str(mapping[keys[match.lastindex - 1]]), format_string) string = re.sub(pattern, lambda match: str(mapping[keys[match.lastindex - 1]]), format_string)
return 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. Similar to `schedule_tasks`, but wipe and reset the tasklist.
""" """
self._taskmap = {tid: time for tid, time in tasks} 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() self._wakeup.set()
def schedule_tasks(self, *tasks: tuple[Taskid, int]) -> None: 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. we build an entirely new list, and always wake up the loop.
""" """
self._taskmap |= {tid: time for tid, time in tasks} 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() self._wakeup.set()
def schedule_task(self, taskid: Taskid, timestamp: int) -> None: 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 # Refresh lock, to avoid cache collisions on refresh
self._refresh_lock = asyncio.Lock() 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 ----- # ----- UI API -----
async def run(self, interaction: discord.Interaction, **kwargs): async def run(self, interaction: discord.Interaction, **kwargs):
""" """
@@ -366,6 +373,15 @@ class MessageUI(LeoUI):
args = await self.make_message() args = await self.make_message()
self._message = await channel.send(**args.send_args, view=self) 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): async def redraw(self, thinking: Optional[discord.Interaction] = None):
""" """
Update the output message for this UI. Update the output message for this UI.
@@ -379,13 +395,7 @@ class MessageUI(LeoUI):
asyncio.create_task(thinking.delete_original_response()) asyncio.create_task(thinking.delete_original_response())
try: try:
if self._original and not self._original.is_expired(): await self._redraw(args)
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()
except discord.HTTPException: except discord.HTTPException:
# Unknown communication erorr, nothing we can reliably do. Exit quietly. # Unknown communication erorr, nothing we can reliably do. Exit quietly.
await self.close() await self.close()

View File

@@ -167,7 +167,10 @@ class ModalRetryUI(LeoUI):
async def respond_to(self, interaction): async def respond_to(self, interaction):
self._interaction = 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") @button(label="Retry")
async def retry_button(self, interaction, butt): async def retry_button(self, interaction, butt):