rewrite: Complete rolemenu system.
This commit is contained in:
@@ -928,6 +928,92 @@ INSERT INTO schedule_session_members (guildid, userid, slotid, booked_at, attend
|
||||
-- Drop old schema
|
||||
-- }}}
|
||||
|
||||
-- Role Menus {{{
|
||||
CREATE TYPE RoleMenuType AS ENUM(
|
||||
'REACTION',
|
||||
'BUTTON',
|
||||
'DROPDOWN'
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE role_menus(
|
||||
menuid SERIAL PRIMARY KEY,
|
||||
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
|
||||
channelid BIGINT,
|
||||
messageid BIGINT,
|
||||
name TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT True,
|
||||
required_roleid BIGINT,
|
||||
sticky BOOLEAN,
|
||||
refunds BOOLEAN,
|
||||
obtainable INTEGER,
|
||||
menutype RoleMenuType NOT NULL,
|
||||
templateid INTEGER,
|
||||
rawmessage TEXT,
|
||||
default_price INTEGER,
|
||||
event_log BOOLEAN
|
||||
);
|
||||
CREATE INDEX role_menu_guildid ON role_menus (guildid);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE role_menu_roles(
|
||||
menuroleid SERIAL PRIMARY KEY,
|
||||
menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE,
|
||||
roleid BIGINT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
emoji TEXT,
|
||||
description TEXT,
|
||||
price INTEGER,
|
||||
duration INTEGER,
|
||||
rawreply TEXT
|
||||
);
|
||||
CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid);
|
||||
CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid);
|
||||
|
||||
|
||||
CREATE TABLE role_menu_history(
|
||||
equipid SERIAL PRIMARY KEY,
|
||||
menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE,
|
||||
roleid BIGINT NOT NULL,
|
||||
userid BIGINT NOT NULL,
|
||||
obtained_at TIMESTAMPTZ NOT NULL,
|
||||
transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
removed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid);
|
||||
CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid);
|
||||
|
||||
|
||||
-- Migration
|
||||
INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype)
|
||||
SELECT
|
||||
messageid, guildid, channelid, enabled,
|
||||
required_role, NOT removable, maximum,
|
||||
refunds, event_log, default_price, messageid :: TEXT,
|
||||
'REACTION'
|
||||
FROM reaction_role_messages;
|
||||
|
||||
INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration)
|
||||
SELECT
|
||||
role_menus.menuid, reactions.roleid, reactions.roleid::TEXT,
|
||||
COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name),
|
||||
reactions.price, reactions.timeout
|
||||
FROM reaction_role_reactions reactions
|
||||
LEFT JOIN role_menus
|
||||
ON role_menus.messageid = reactions.messageid;
|
||||
|
||||
INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at)
|
||||
SELECT
|
||||
rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry
|
||||
FROM reaction_role_expiring expiring
|
||||
LEFT JOIN role_menu_roles rmr
|
||||
ON rmr.roleid = expiring.roleid
|
||||
WHERE rmr.menuid IS NOT NULL;
|
||||
-- }}}
|
||||
|
||||
|
||||
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -24,8 +24,12 @@ CREATE TABLE role_menus(
|
||||
obtainable INTEGER,
|
||||
menutype RoleMenuType NOT NULL,
|
||||
templateid INTEGER,
|
||||
rawmessage TEXT
|
||||
rawmessage TEXT,
|
||||
default_price INTEGER,
|
||||
event_log BOOLEAN
|
||||
);
|
||||
CREATE INDEX role_menu_guildid ON role_menus (guildid);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE role_menu_roles(
|
||||
@@ -39,6 +43,8 @@ CREATE TABLE role_menu_roles(
|
||||
duration INTEGER,
|
||||
rawreply TEXT
|
||||
);
|
||||
CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid);
|
||||
CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid);
|
||||
|
||||
|
||||
CREATE TABLE role_menu_history(
|
||||
@@ -49,5 +55,35 @@ CREATE TABLE role_menu_history(
|
||||
obtained_at TIMESTAMPTZ NOT NULL,
|
||||
transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
expired_at TIMESTAMPTZ
|
||||
removed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid);
|
||||
CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid);
|
||||
|
||||
|
||||
-- Migration
|
||||
INSERT INTO role_menus (messageid, guildid, channelid, enabled, required_roleid, sticky, obtainable, refunds, event_log, default_price, name, menutype)
|
||||
SELECT
|
||||
messageid, guildid, channelid, enabled,
|
||||
required_role, NOT removable, maximum,
|
||||
refunds, event_log, default_price, messageid :: TEXT,
|
||||
'REACTION'
|
||||
FROM reaction_role_messages;
|
||||
|
||||
INSERT INTO role_menu_roles (menuid, roleid, label, emoji, price, duration)
|
||||
SELECT
|
||||
role_menus.menuid, reactions.roleid, reactions.roleid::TEXT,
|
||||
COALESCE('<:' || reactions.emoji_name || ':' || reactions.emoji_id :: TEXT || '>', reactions.emoji_name),
|
||||
reactions.price, reactions.timeout
|
||||
FROM reaction_role_reactions reactions
|
||||
LEFT JOIN role_menus
|
||||
ON role_menus.messageid = reactions.messageid;
|
||||
|
||||
INSERT INTO role_menu_history (menuid, roleid, userid, obtained_at, expires_at)
|
||||
SELECT
|
||||
rmr.menuid, expiring.roleid, expiring.userid, NOW(), expiring.expiry
|
||||
FROM reaction_role_expiring expiring
|
||||
LEFT JOIN role_menu_roles rmr
|
||||
ON rmr.roleid = expiring.roleid
|
||||
WHERE rmr.menuid IS NOT NULL;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import discord
|
||||
from meta import LionBot
|
||||
from utils.lib import Timezoned
|
||||
from settings.groups import ModelConfig, SettingDotDict
|
||||
from babel.translator import SOURCE_LOCALE
|
||||
|
||||
from .data import CoreData
|
||||
from .lion_user import LionUser
|
||||
@@ -63,6 +64,21 @@ class LionMember(Timezoned):
|
||||
guild_timezone = self.lguild.config.timezone
|
||||
return user_timezone.value if user_timezone._data is not None else guild_timezone.value
|
||||
|
||||
def private_locale(self, interaction=None) -> str:
|
||||
"""
|
||||
Appropriate locale to use in private communication with this member.
|
||||
|
||||
Does not take into account guild force_locale.
|
||||
"""
|
||||
user_locale = self.luser.config.get('user_locale').value
|
||||
interaction_locale = interaction.locale.value if interaction else None
|
||||
guild_locale = self.lguild.config.get('guild_locale').value
|
||||
|
||||
locale = user_locale or interaction_locale
|
||||
locale = locale or guild_locale
|
||||
locale = locale or SOURCE_LOCALE
|
||||
return locale
|
||||
|
||||
async def touch_discord_model(self, member: discord.Member):
|
||||
"""
|
||||
Update saved Discord model attributes for this member.
|
||||
@@ -82,3 +98,15 @@ class LionMember(Timezoned):
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return member
|
||||
|
||||
async def remove_role(self, role: discord.Role):
|
||||
member = await self.fetch_member()
|
||||
if member is not None and role in member.roles:
|
||||
try:
|
||||
await member.remove_roles(role)
|
||||
except discord.HTTPException:
|
||||
# TODO: Logging, audit logging
|
||||
pass
|
||||
else:
|
||||
# TODO: Persistent role removal
|
||||
...
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""
|
||||
Additional abstract setting types useful for StudyLion settings.
|
||||
"""
|
||||
from settings.setting_types import IntegerSetting
|
||||
from typing import Optional
|
||||
import json
|
||||
import traceback
|
||||
|
||||
import discord
|
||||
|
||||
from settings.base import ParentID
|
||||
from settings.setting_types import IntegerSetting, StringSetting
|
||||
from meta import conf
|
||||
from meta.errors import UserInputError
|
||||
from constants import MAX_COINS
|
||||
@@ -62,3 +69,202 @@ class CoinSetting(IntegerSetting):
|
||||
"{coin}**{amount}**"
|
||||
)).format(coin=conf.emojis.coin, amount=data)
|
||||
return formatted
|
||||
|
||||
|
||||
class MessageSetting(StringSetting):
|
||||
"""
|
||||
Typed Setting ABC representing a message sent to Discord.
|
||||
|
||||
Data is a json-formatted string dict with at least one of the fields 'content', 'embed', 'embeds'
|
||||
Value is the corresponding dictionary
|
||||
"""
|
||||
# TODO: Extend to support format keys
|
||||
|
||||
_accepts = _p(
|
||||
'settype:message|accepts',
|
||||
"JSON formatted raw message data"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def download_attachment(attached: discord.Attachment):
|
||||
"""
|
||||
Download a discord.Attachment with some basic filetype and file size validation.
|
||||
"""
|
||||
t = ctx_translator.get().t
|
||||
|
||||
error = None
|
||||
decoded = None
|
||||
if attached.content_type and not ('json' in attached.content_type):
|
||||
error = t(_p(
|
||||
'settype:message|download|error:not_json',
|
||||
"The attached message data is not a JSON file!"
|
||||
))
|
||||
elif attached.size > 10000:
|
||||
error = t(_p(
|
||||
'settype:message|download|error:size',
|
||||
"The attached message data is too large!"
|
||||
))
|
||||
else:
|
||||
content = await attached.read()
|
||||
try:
|
||||
decoded = content.decode('UTF-8')
|
||||
except UnicodeDecodeError:
|
||||
error = t(_p(
|
||||
'settype:message|download|error:decoding',
|
||||
"Could not decode the message data. Please ensure it is saved with the `UTF-8` encoding."
|
||||
))
|
||||
|
||||
if error is not None:
|
||||
raise UserInputError(error)
|
||||
else:
|
||||
return decoded
|
||||
|
||||
@classmethod
|
||||
def value_to_args(cls, parent_id: ParentID, value: dict, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
args = {}
|
||||
args['content'] = value.get('content', "")
|
||||
if 'embed' in value:
|
||||
embed = discord.Embed.from_dict(value['embed'])
|
||||
args['embed'] = embed
|
||||
if 'embeds' in value:
|
||||
embeds = []
|
||||
for embed_data in value['embeds']:
|
||||
embeds.append(discord.Embed.from_dict(embed_data))
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs):
|
||||
if value and any(value.get(key, None) for key in ('content', 'embed', 'embeds')):
|
||||
data = json.dumps(value)
|
||||
else:
|
||||
data = None
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, parent_id: ParentID, data: Optional[str], **kwargs):
|
||||
if data:
|
||||
value = json.loads(data)
|
||||
else:
|
||||
value = None
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id: ParentID, string: str, **kwargs):
|
||||
"""
|
||||
Provided user string can be downright random.
|
||||
|
||||
If it isn't json-formatted, treat it as the content of the message.
|
||||
If it is, do basic checking on the length and embeds.
|
||||
"""
|
||||
string = string.strip()
|
||||
if not string or string.lower() == 'none':
|
||||
return None
|
||||
|
||||
t = ctx_translator.get().t
|
||||
|
||||
error_tip = t(_p(
|
||||
'settype:message|error_suffix',
|
||||
"You can view, test, and fix your embed using the online [embed builder]({link})."
|
||||
)).format(
|
||||
link="https://glitchii.github.io/embedbuilder/?editor=json"
|
||||
)
|
||||
|
||||
if string.startswith('{') and string.endswith('}'):
|
||||
# Assume the string is a json-formatted message dict
|
||||
try:
|
||||
value = json.loads(string)
|
||||
except json.JSONDecodeError as err:
|
||||
error = t(_p(
|
||||
'settype:message|error:invalid_json',
|
||||
"The provided message data was not a valid JSON document!\n"
|
||||
"`{error}`"
|
||||
)).format(error=str(err))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
if not isinstance(value, dict) or not any(value.get(key, None) for key in ('content', 'embed', 'embeds')):
|
||||
error = t(_p(
|
||||
'settype:message|error:json_missing_keys',
|
||||
"Message data must be a JSON object with at least one of the following fields: "
|
||||
"`content`, `embed`, `embeds`"
|
||||
))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
embed_data = value.get('embed', None)
|
||||
if not isinstance(embed_data, dict):
|
||||
error = t(_p(
|
||||
'settype:message|error:json_embed_type',
|
||||
"`embed` field must be a valid JSON object."
|
||||
))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
embeds_data = value.get('embeds', [])
|
||||
if not isinstance(embeds_data, list):
|
||||
error = t(_p(
|
||||
'settype:message|error:json_embeds_type',
|
||||
"`embeds` field must be a list."
|
||||
))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
if embed_data and embeds_data:
|
||||
error = t(_p(
|
||||
'settype:message|error:json_embed_embeds',
|
||||
"Message data cannot include both `embed` and `embeds`."
|
||||
))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
content_data = value.get('content', "")
|
||||
if not isinstance(content_data, str):
|
||||
error = t(_p(
|
||||
'settype:message|error:json_content_type',
|
||||
"`content` field must be a string."
|
||||
))
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
# Validate embeds, which is the most likely place for something to go wrong
|
||||
embeds = [embed_data] if embed_data else embeds_data
|
||||
try:
|
||||
for embed in embeds:
|
||||
discord.Embed.from_dict(embed)
|
||||
except Exception as e:
|
||||
# from_dict may raise a range of possible exceptions.
|
||||
raw_error = ''.join(
|
||||
traceback.TracebackException.from_exception(e).format_exception_only()
|
||||
)
|
||||
error = t(_p(
|
||||
'ui:settype:message|error:embed_conversion',
|
||||
"Could not parse the message embed data.\n"
|
||||
"**Error:** `{exception}`"
|
||||
)).format(exception=raw_error)
|
||||
raise UserInputError(error + '\n' + error_tip)
|
||||
|
||||
# At this point, the message will at least successfully convert into MessageArgs
|
||||
# There are numerous ways it could still be invalid, e.g. invalid urls, or too-long fields
|
||||
# or the total message content being too long, or too many fields, etc
|
||||
# This will need to be caught in anything which displays a message parsed from user data.
|
||||
else:
|
||||
# Either the string is not json formatted, or the formatting is broken
|
||||
# Assume the string is a content message
|
||||
value = {
|
||||
'content': string
|
||||
}
|
||||
return json.dumps(value)
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, parent_id: ParentID, data: Optional[str], **kwargs):
|
||||
if not data:
|
||||
return None
|
||||
|
||||
value = cls._data_to_value(parent_id, data, **kwargs)
|
||||
content = value.get('content', "")
|
||||
if 'embed' in value or 'embeds' in value or len(content) > 1024:
|
||||
t = ctx_translator.get().t
|
||||
formatted = t(_p(
|
||||
'settype:message|format:too_long',
|
||||
"Too long to display! See Preview."
|
||||
))
|
||||
else:
|
||||
formatted = content
|
||||
|
||||
return formatted
|
||||
|
||||
@@ -323,11 +323,10 @@ class Reminders(LionCog):
|
||||
logger.debug(
|
||||
f"Executed reminder <rid: {reminder.reminderid}>."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
except discord.HTTPException as e:
|
||||
await reminder.update(failed=True)
|
||||
logger.debug(
|
||||
f"Reminder <rid: {reminder.reminderid}> could not be sent.",
|
||||
exc_info=True
|
||||
f"Reminder <rid: {reminder.reminderid}> could not be sent: {e.text}",
|
||||
)
|
||||
except Exception:
|
||||
await reminder.update(failed=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
from data import Registry, RowModel, RegisterEnum, Column
|
||||
from data import Registry, RowModel, RegisterEnum, Column, NULL
|
||||
from data.columns import Integer, Timestamp, String, Bool
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ class RoleMenuData(Registry):
|
||||
templateid = Integer()
|
||||
rawmessage = String()
|
||||
|
||||
default_price = Integer()
|
||||
event_log = Bool()
|
||||
|
||||
class RoleMenuRole(RowModel):
|
||||
_tablename_ = 'role_menu_roles'
|
||||
_cache_ = {}
|
||||
@@ -66,4 +69,17 @@ class RoleMenuData(Registry):
|
||||
obtained_at = Timestamp()
|
||||
transactionid = Integer()
|
||||
expires_at = Timestamp()
|
||||
expired_at = Timestamp()
|
||||
removed_at = Timestamp()
|
||||
|
||||
@classmethod
|
||||
def fetch_expiring_where(cls, *args, **kwargs):
|
||||
"""
|
||||
Fetch expiring equip rows.
|
||||
|
||||
This returns an awaitable and chainable Select Query.
|
||||
"""
|
||||
return cls.fetch_where(
|
||||
(cls.expires_at != NULL),
|
||||
(cls.removed_at == NULL),
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
@@ -10,16 +10,14 @@ from settings.setting_types import (
|
||||
RoleSetting, BoolSetting, StringSetting, IntegerSetting, DurationSetting
|
||||
)
|
||||
|
||||
from core.setting_types import MessageSetting
|
||||
|
||||
from .data import RoleMenuData
|
||||
from . import babel
|
||||
|
||||
_p = babel._p
|
||||
|
||||
|
||||
# TODO: Write some custom accepts fields
|
||||
# TODO: The *name* might be an important setting!
|
||||
|
||||
|
||||
class RoleMenuConfig(ModelConfig):
|
||||
settings = SettingDotDict()
|
||||
_model_settings = set()
|
||||
@@ -45,6 +43,10 @@ class RoleMenuConfig(ModelConfig):
|
||||
def obtainable(self):
|
||||
return self.get(RoleMenuOptions.Obtainable.setting_id)
|
||||
|
||||
@property
|
||||
def rawmessage(self):
|
||||
return self.get(RoleMenuOptions.Message.setting_id)
|
||||
|
||||
|
||||
class RoleMenuOptions(SettingGroup):
|
||||
@RoleMenuConfig.register_model_setting
|
||||
@@ -56,6 +58,7 @@ class RoleMenuOptions(SettingGroup):
|
||||
'menuset:name|desc',
|
||||
"Brief name for this role menu."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'menuset:name|long_desc',
|
||||
"The role menu name is displayed when selecting the menu in commands, "
|
||||
@@ -66,6 +69,16 @@ class RoleMenuOptions(SettingGroup):
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.name.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
resp = t(_p(
|
||||
'menuset:name|set_response',
|
||||
"This role menu will now be called **{new_name}**."
|
||||
)).format(new_name=value)
|
||||
return resp
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Sticky(ModelData, BoolSetting):
|
||||
setting_id = 'sticky'
|
||||
@@ -75,6 +88,7 @@ class RoleMenuOptions(SettingGroup):
|
||||
'menuset:sticky|desc',
|
||||
"Whether the menu can be used to unequip roles."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'menuset:sticky|long_desc',
|
||||
"When enabled, members will not be able to remove equipped roles by selecting them in this menu. "
|
||||
@@ -86,6 +100,22 @@ class RoleMenuOptions(SettingGroup):
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.sticky.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'menuset:sticky|set_response:true',
|
||||
"Members will no longer be able to remove roles with this menu."
|
||||
))
|
||||
else:
|
||||
resp = t(_p(
|
||||
'menuset:sticky|set_response:false',
|
||||
"Members will now be able to remove roles with this menu."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Refunds(ModelData, BoolSetting):
|
||||
setting_id = 'refunds'
|
||||
@@ -95,6 +125,7 @@ class RoleMenuOptions(SettingGroup):
|
||||
'menuset:refunds|desc',
|
||||
"Whether removing a role will refund the purchase price for that role."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'menuset:refunds|long_desc',
|
||||
"When enabled, members who *purchased a role through this role menu* will obtain a full refund "
|
||||
@@ -107,6 +138,22 @@ class RoleMenuOptions(SettingGroup):
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.refunds.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'menuset:refunds|set_response:true',
|
||||
"Members will now be refunded when removing a role with this menu."
|
||||
))
|
||||
else:
|
||||
resp = t(_p(
|
||||
'menuset:refunds|set_response:false',
|
||||
"Members will no longer be refunded when removing a role with this menu."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Obtainable(ModelData, IntegerSetting):
|
||||
setting_id = 'obtainable'
|
||||
@@ -116,16 +163,37 @@ class RoleMenuOptions(SettingGroup):
|
||||
'menuset:obtainable|desc',
|
||||
"The maximum number of roles equippable from this menu."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'menus:obtainable|long_desc',
|
||||
'menuset:obtainable|long_desc',
|
||||
"Members will not be able to obtain more than this number of roles from this menu. "
|
||||
"The counts roles that were not obtained through the rolemenu system."
|
||||
"This counts roles that were not obtained through the rolemenu system."
|
||||
)
|
||||
_notset_str = _p(
|
||||
'menuset:obtainable|notset',
|
||||
"Unlimited."
|
||||
)
|
||||
_default = None
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.obtainable.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'menuset:obtainable|set_response:set',
|
||||
"Members will be able to select a maximum of **{value}** roles from this menu."
|
||||
)).format(value=value)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'menuset:obtainable|set_response:unset',
|
||||
"Members will be able to select any number of roles from this menu."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class RequiredRole(ModelData, RoleSetting):
|
||||
setting_id = 'required_role'
|
||||
@@ -135,6 +203,7 @@ class RoleMenuOptions(SettingGroup):
|
||||
'menuset:required_role|desc',
|
||||
"Initial role required to use this menu."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'menuset:required_role|long_desc',
|
||||
"If set, only members who have the `required_role` will be able to obtain or remove roles using this menu."
|
||||
@@ -143,3 +212,56 @@ class RoleMenuOptions(SettingGroup):
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.required_roleid.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'menuset:required_role|set_response:set',
|
||||
"Members will need to have the {role} role to use this menu."
|
||||
)).format(role=self.formatted)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'menuset:required_role|set_response:unset',
|
||||
"Any member who can see the menu may use it."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuConfig.register_model_setting
|
||||
class Message(ModelData, MessageSetting):
|
||||
setting_id = 'message'
|
||||
|
||||
_display_name = _p('menuset:message', "custom_message")
|
||||
_desc = _p(
|
||||
'menuset:message|desc',
|
||||
"Custom message data used to display the menu."
|
||||
)
|
||||
_long_desc = _p(
|
||||
'menuset:message|long_desc',
|
||||
"This setting determines the body of the menu message, "
|
||||
"including the message content and the message embed(s). "
|
||||
"While most easily modifiable through the `Edit Message` button, "
|
||||
"raw JSON-formatted message data may also be uploaded via command."
|
||||
)
|
||||
_default = None
|
||||
|
||||
_model = RoleMenuData.RoleMenu
|
||||
_column = RoleMenuData.RoleMenu.rawmessage.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'menuset:message|set_response:set',
|
||||
"The role menu message has been set. Edit through the menu editor."
|
||||
)).format(value=value.mention)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'menuset:message|set_response:unset',
|
||||
"The role menu message has been unset. Select a template through the menu editor."
|
||||
))
|
||||
return resp
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
from meta import LionBot
|
||||
import json
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import datetime as dt
|
||||
from collections import defaultdict
|
||||
|
||||
import discord
|
||||
from discord.ui.select import Select, SelectOption
|
||||
from discord.ui.button import Button, ButtonStyle
|
||||
|
||||
from meta import LionBot
|
||||
from meta.errors import UserInputError, SafeCancellation
|
||||
from utils.ui import MessageArgs, HookedItem, AsComponents
|
||||
from utils.lib import utc_now, jumpto, emojikey
|
||||
from babel.translator import ctx_locale
|
||||
|
||||
from modules.economy.cog import Economy, EconomyData, TransactionType
|
||||
|
||||
from .data import RoleMenuData as Data
|
||||
from .data import MenuType
|
||||
from .menuoptions import RoleMenuConfig
|
||||
from .roleoptions import RoleMenuRoleConfig
|
||||
from .templates import templates
|
||||
from . import logger, babel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cog import RoleMenuCog
|
||||
|
||||
_p = babel._p
|
||||
|
||||
MISSING = object()
|
||||
|
||||
DEFAULT_EMOJIS = "🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🫐 🍓 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🫒 🥦 🥬 🫑 🥒".split()
|
||||
DEFAULT_EMOJIS_PARTIALS = [discord.PartialEmoji(name=string) for string in DEFAULT_EMOJIS]
|
||||
|
||||
|
||||
class MenuDropdown(HookedItem, Select):
|
||||
...
|
||||
|
||||
|
||||
class MenuButton(HookedItem, Button):
|
||||
...
|
||||
|
||||
|
||||
class RoleMenuRole:
|
||||
@@ -12,31 +47,678 @@ class RoleMenuRole:
|
||||
self.data = data
|
||||
self.config = RoleMenuRoleConfig(data.menuroleid, data)
|
||||
|
||||
@property
|
||||
def custom_id(self):
|
||||
return f"rmrid:{self.data.menuroleid}"
|
||||
|
||||
@property
|
||||
def as_option(self):
|
||||
return SelectOption(
|
||||
emoji=self.config.emoji.data or None,
|
||||
label=self.config.label.value,
|
||||
value=str(self.data.menuroleid),
|
||||
description=self.config.description.value,
|
||||
)
|
||||
|
||||
@property
|
||||
def as_button(self):
|
||||
@MenuButton(
|
||||
emoji=self.config.emoji.data or None,
|
||||
label=self.config.label.value,
|
||||
custom_id=self.custom_id,
|
||||
style=ButtonStyle.grey
|
||||
)
|
||||
async def menu_button(press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True, ephemeral=True)
|
||||
menu = await RoleMenu.fetch(self.bot, self.data.menuid)
|
||||
await menu.interactive_selection(press, self.data.menuroleid)
|
||||
|
||||
return menu_button
|
||||
|
||||
|
||||
class RoleMenu:
|
||||
def __init__(self, bot: LionBot, data: Data.RoleMenu, roles):
|
||||
# Cache of messages with listening menus attached
|
||||
attached_menus = defaultdict(dict) # guildid -> messageid -> menuid
|
||||
|
||||
# Registry of persistent Views for given menus
|
||||
menu_views = {} # menuid -> View
|
||||
|
||||
# Persistent cache of menus
|
||||
_menus = {} # menuid -> Menu
|
||||
|
||||
def __init__(self, bot: LionBot, data: Data.RoleMenu, rolemap):
|
||||
self.bot = bot
|
||||
self.cog: 'RoleMenuCog' = bot.get_cog('RoleMenuCog')
|
||||
self.data = data
|
||||
self.config = RoleMenuConfig(data.menuid, data)
|
||||
self.roles: list[RoleMenuRole] = roles
|
||||
self.rolemap: dict[int, RoleMenuRole] = rolemap
|
||||
self.roles = list(rolemap.values())
|
||||
|
||||
self._message = None
|
||||
self._message = MISSING
|
||||
|
||||
@property
|
||||
def _view(self) -> Optional[discord.ui.View]:
|
||||
"""
|
||||
Active persistent View for this menu.
|
||||
"""
|
||||
return self.menu_views.get(self.data.menuid, None)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
if self._message is MISSING:
|
||||
raise ValueError("Cannot access menu message before fetch")
|
||||
else:
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def jump_link(self):
|
||||
if self.data.messageid:
|
||||
link = jumpto(
|
||||
self.data.guildid,
|
||||
self.data.channelid,
|
||||
self.data.messageid
|
||||
)
|
||||
else:
|
||||
link = None
|
||||
return link
|
||||
|
||||
@property
|
||||
def managed(self):
|
||||
"""
|
||||
Whether the menu message is owned by the bot.
|
||||
|
||||
Returns True if the menu is unattached.
|
||||
"""
|
||||
if self._message is MISSING:
|
||||
# Unknown, but send falsey value
|
||||
managed = None
|
||||
elif self._message is None:
|
||||
managed = True
|
||||
elif self._message.author is self._message.guild.me:
|
||||
managed = True
|
||||
else:
|
||||
managed = False
|
||||
return managed
|
||||
|
||||
@classmethod
|
||||
async def fetch(cls, bot: LionBot, menuid: int):
|
||||
"""
|
||||
Fetch the requested menu by id, applying registry cache where possible.
|
||||
"""
|
||||
if (menu := cls._menus.get(menuid, None)) is None:
|
||||
cog = bot.get_cog('RoleMenuCog')
|
||||
data = await cog.data.RoleMenu.fetch(menuid)
|
||||
role_rows = await cog.data.RoleMenuRole.fetch_where(menuid=menuid).order_by('menuroleid')
|
||||
rolemap = {row.menuroleid: RoleMenuRole(bot, row) for row in role_rows}
|
||||
menu = cls(bot, data, rolemap)
|
||||
cls._menus[menuid] = menu
|
||||
return menu
|
||||
|
||||
@classmethod
|
||||
async def create(cls, bot: LionBot, **data_args):
|
||||
cog = bot.get_cog('RoleMenuCog')
|
||||
data = await cog.data.RoleMenu.create(
|
||||
**data_args
|
||||
)
|
||||
menu = cls(bot, data, {})
|
||||
cls._menus[data.menuid] = menu
|
||||
await menu.attach()
|
||||
return menu
|
||||
|
||||
async def fetch_message(self, refresh=False):
|
||||
"""
|
||||
Fetch the message the menu is attached to.
|
||||
"""
|
||||
if refresh or self._message is MISSING:
|
||||
if self.data.messageid is None:
|
||||
_message = None
|
||||
else:
|
||||
_message = None
|
||||
channelid = self.data.channelid
|
||||
channel = self.bot.get_channel(channelid)
|
||||
if channel is not None:
|
||||
try:
|
||||
_message = await channel.fetch_message(self.data.messageid)
|
||||
except discord.NotFound:
|
||||
pass
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
except discord.HTTPException:
|
||||
# Something unexpected went wrong, leave the data alone for now
|
||||
logger.exception("Something unexpected occurred while fetching the menu message")
|
||||
raise
|
||||
if _message is None:
|
||||
await self.data.update(messageid=None)
|
||||
self._message = _message
|
||||
return self._message
|
||||
|
||||
async def fetch_message(self):
|
||||
...
|
||||
def emoji_map(self):
|
||||
emoji_map = {}
|
||||
for mrole in self.roles:
|
||||
emoji = mrole.config.emoji.as_partial
|
||||
if emoji is not None:
|
||||
emoji_map[emoji] = mrole.data.menuroleid
|
||||
return emoji_map
|
||||
|
||||
async def reload(self):
|
||||
await self.data.refresh()
|
||||
async def attach(self):
|
||||
"""
|
||||
Start listening for menu selection events.
|
||||
"""
|
||||
if self.data.messageid:
|
||||
self.attached_menus[self.data.guildid][self.data.messageid] = self.data.menuid
|
||||
if self.data.menutype is not MenuType.REACTION:
|
||||
view = await self.make_view()
|
||||
if view is not None:
|
||||
self.menu_views[self.data.menuid] = view
|
||||
self.bot.add_view(view)
|
||||
elif self.data.menutype is MenuType.REACTION:
|
||||
pass
|
||||
|
||||
def detach(self):
|
||||
"""
|
||||
Stop listening for menu selection events.
|
||||
"""
|
||||
view = self.menu_views.pop(self.data.menuid, None)
|
||||
if view is not None:
|
||||
view.stop()
|
||||
if (mid := self.data.messageid) is not None:
|
||||
self.attached_menus[self.data.guildid].pop(mid, None)
|
||||
|
||||
async def delete(self):
|
||||
self.detach()
|
||||
self._menus.pop(self.data.menuid, None)
|
||||
|
||||
# Delete the menu, along with the message if it is self-managed.
|
||||
message = await self.fetch_message()
|
||||
if message and message.author is message.guild.me:
|
||||
try:
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
# This should never really fail since we own the message
|
||||
# But it is possible the message was externally deleted and we never updated message cache
|
||||
# So just ignore quietly
|
||||
pass
|
||||
|
||||
# Cancel any relevant expiry tasks (before we delete data which will delete the equip rows)
|
||||
expiring = await self.cog.data.RoleMenuHistory.fetch_expiring_where(menuid=self.data.menuid)
|
||||
if expiring:
|
||||
await self.cog.cancel_expiring_tasks(*(row.equipid for row in expiring))
|
||||
await self.data.delete()
|
||||
|
||||
async def reload_roles(self):
|
||||
"""
|
||||
Fetches and re-initialises the MenuRoles for this Menu.
|
||||
"""
|
||||
roledata = self.bot.get_cog('RoleMenuCog').data.RoleMenuRole
|
||||
role_rows = await roledata.fetch_where(menuid=self.data.menuid)
|
||||
self.roles = [RoleMenuRole(self.bot, row) for row in role_rows]
|
||||
role_rows = await roledata.fetch_where(menuid=self.data.menuid).order_by('menuroleid')
|
||||
self.rolemap = {row.menuroleid: RoleMenuRole(self.bot, row) for row in role_rows}
|
||||
self.roles = list(self.rolemap.values())
|
||||
|
||||
async def make_view(self):
|
||||
...
|
||||
async def update_message(self):
|
||||
"""
|
||||
Update the (managed) message the menu is attached to.
|
||||
|
||||
async def make_args(self):
|
||||
...
|
||||
Does nothing if there is not message or it is not bot-managed.
|
||||
"""
|
||||
self.detach()
|
||||
message = await self.fetch_message()
|
||||
if message is not None and self.managed:
|
||||
args = await self.make_args()
|
||||
view = await self.make_view()
|
||||
try:
|
||||
await message.edit(**args.edit_args, view=view)
|
||||
await self.attach()
|
||||
except discord.NotFound:
|
||||
await self.data.update(messageid=None)
|
||||
self._message = None
|
||||
except discord.HTTPException as e:
|
||||
t = self.bot.translator.t
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=t(_p(
|
||||
'rolemenu|menu_message|error|title',
|
||||
'ROLE MENU DISPLAY ERROR'
|
||||
)),
|
||||
description=t(_p(
|
||||
'rolemenu|menu_message|error|desc',
|
||||
"A critical error occurred trying to display this role menu.\n"
|
||||
"Error: `{error}`."
|
||||
)).format(error=e.text)
|
||||
)
|
||||
try:
|
||||
await message.edit(
|
||||
embed=error
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# There's really something wrong
|
||||
# Nothing we can safely do.
|
||||
pass
|
||||
pass
|
||||
|
||||
async def update_reactons(self):
|
||||
"""
|
||||
Attempt to update the reactions on a REACTION type menu.
|
||||
|
||||
Does nothing if the menu is not REACTION type.
|
||||
Will raise `SafeCancellation` and stop if a reaction fails.
|
||||
"""
|
||||
message = await self.fetch_message()
|
||||
if message is not None and self.data.menutype is MenuType.REACTION:
|
||||
# First remove any of my reactions that are no longer relevant
|
||||
required = {
|
||||
emojikey(mrole.config.emoji.as_partial) for mrole in self.roles if mrole.data.emoji
|
||||
}
|
||||
for reaction in message.reactions:
|
||||
if reaction.me and (emojikey(reaction.emoji) not in required):
|
||||
try:
|
||||
await message.remove_reaction(reaction.emoji, message.guild.me)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Then add any extra reactions that are missing
|
||||
existing_mine = {
|
||||
emojikey(reaction.emoji) for reaction in message.reactions if reaction.me
|
||||
}
|
||||
existing = {
|
||||
emojikey(reaction.emoji) for reaction in message.reactions
|
||||
}
|
||||
for mrole in self.roles:
|
||||
emoji = mrole.config.emoji.as_partial
|
||||
if emoji is not None and emojikey(emoji) not in existing_mine:
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except discord.HTTPException:
|
||||
if emojikey(emoji) not in existing:
|
||||
t = self.bot.translator.t
|
||||
raise SafeCancellation(
|
||||
t(_p(
|
||||
'rolemenu|update_reactions|error',
|
||||
"Could not add the {emoji} reaction, perhaps I do not "
|
||||
"have access to this emoji! Reactions will need to be added "
|
||||
"manually."
|
||||
)).format(emoji=emoji)
|
||||
)
|
||||
else:
|
||||
# We can't react with this emoji, but it does exist on the message
|
||||
# Just ignore the error and continue
|
||||
continue
|
||||
|
||||
async def repost_to(self, destination):
|
||||
# Set the current message to be deleted if it is a managed message.
|
||||
# Don't delete until after we have successfully moved the menu though.
|
||||
if self.managed and (message := self.message):
|
||||
to_delete = message
|
||||
else:
|
||||
to_delete = None
|
||||
|
||||
# Now try and post the message in the new channel
|
||||
args = await self.make_args()
|
||||
view = await self.make_view()
|
||||
new_message = await destination.send(**args.send_args, view=view or discord.utils.MISSING)
|
||||
|
||||
# Stop listening to events on the current message (if it exists)
|
||||
self.detach()
|
||||
await self.data.update(channelid=destination.id, messageid=new_message.id)
|
||||
self._message = new_message
|
||||
await self.attach()
|
||||
|
||||
if to_delete:
|
||||
# Attempt to delete the original message
|
||||
try:
|
||||
await to_delete.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def _make_button_view(self):
|
||||
buttons = [mrole.as_button for mrole in self.roles]
|
||||
return AsComponents(*buttons, timeout=None)
|
||||
|
||||
async def _make_dropdown_view(self):
|
||||
t = self.bot.translator.t
|
||||
|
||||
placeholder = t(_p(
|
||||
'ui:rolemenu_dropdown|placeholder',
|
||||
"Select Roles"
|
||||
))
|
||||
options = [mrole.as_option for mrole in self.roles]
|
||||
|
||||
@MenuDropdown(
|
||||
custom_id=f"menuid:{self.data.menuid}",
|
||||
placeholder=placeholder,
|
||||
options=options,
|
||||
min_values=0, max_values=1
|
||||
)
|
||||
async def menu_dropdown(selection: discord.Interaction, selected: Select):
|
||||
if selected.values:
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
menuroleid = int(selected.values[0])
|
||||
menu = await self.fetch(self.bot, self.data.menuid)
|
||||
await menu.interactive_selection(selection, menuroleid)
|
||||
else:
|
||||
await selection.response.defer(thinking=False)
|
||||
|
||||
return AsComponents(menu_dropdown, timeout=None)
|
||||
|
||||
async def make_view(self) -> Optional[discord.ui.View]:
|
||||
"""
|
||||
Create the appropriate discord.View for this menu.
|
||||
|
||||
May be None if the menu has no roles or is a REACTION menu.
|
||||
"""
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.data.guildid)
|
||||
ctx_locale.set(lguild.locale)
|
||||
if not self.roles:
|
||||
view = None
|
||||
elif self.data.menutype is MenuType.REACTION:
|
||||
view = None
|
||||
elif self.data.menutype is MenuType.DROPDOWN:
|
||||
view = await self._make_dropdown_view()
|
||||
elif self.data.menutype is MenuType.BUTTON:
|
||||
view = await self._make_button_view()
|
||||
return view
|
||||
|
||||
async def make_args(self) -> MessageArgs:
|
||||
"""
|
||||
Generate the message arguments for this menu.
|
||||
"""
|
||||
if (tid := self.data.templateid) is not None:
|
||||
# Apply template
|
||||
template = templates[tid]
|
||||
args = await template.render_menu(self)
|
||||
else:
|
||||
raw = self.data.rawmessage
|
||||
data = json.loads(raw)
|
||||
args = MessageArgs(
|
||||
content=data.get('content', ''),
|
||||
embed=discord.Embed.from_dict(data['embed']) if 'embed' in data else None
|
||||
)
|
||||
return args
|
||||
|
||||
def unused_emojis(self, include_defaults=True):
|
||||
"""
|
||||
Fetch the next emoji on the message that is not already assigned to a role.
|
||||
Checks custom emojis by PartialEmoji equality (i.e. by id).
|
||||
|
||||
If no reaction exists, uses a default emoji.
|
||||
"""
|
||||
if self.message:
|
||||
message_emojis = [reaction.emoji for reaction in self.message.reactions]
|
||||
else:
|
||||
message_emojis = []
|
||||
if self.data.menutype is MenuType.REACTION:
|
||||
valid_emojis = (*message_emojis, *DEFAULT_EMOJIS_PARTIALS)
|
||||
else:
|
||||
valid_emojis = message_emojis
|
||||
menu_emojis = {emojikey(mrole.config.emoji.as_partial) for mrole in self.roles}
|
||||
for emoji in valid_emojis:
|
||||
if emojikey(emoji) not in menu_emojis:
|
||||
yield str(emoji)
|
||||
|
||||
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
|
||||
mrole = self.rolemap.get(menuroleid, None)
|
||||
if mrole is None:
|
||||
raise ValueError(f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN.")
|
||||
|
||||
guild = member.guild
|
||||
|
||||
t = self.bot.translator.t
|
||||
|
||||
role = guild.get_role(mrole.data.roleid)
|
||||
if role is None:
|
||||
# This role no longer exists, nothing we can do
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|error:role_gone',
|
||||
"This role no longer exists!"
|
||||
))
|
||||
)
|
||||
if role in member.roles:
|
||||
# Member already has the role, deselection case.
|
||||
if self.config.sticky.value:
|
||||
# Cannot deselect
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|deselect|error:sticky',
|
||||
"{role} is a sticky role, you cannot remove it with this menu!"
|
||||
)).format(role=role.mention)
|
||||
)
|
||||
|
||||
conn = await self.bot.db.get_connection()
|
||||
async with conn.transaction():
|
||||
# Remove the role
|
||||
try:
|
||||
await member.remove_roles(role)
|
||||
except discord.Forbidden:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|deselect|error:perms',
|
||||
"I don't have enough permissions to remove this role from you!"
|
||||
))
|
||||
)
|
||||
except discord.HTTPException:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|deselect|error:discord',
|
||||
"An unknown error occurred removing your role! Please try again later."
|
||||
))
|
||||
)
|
||||
|
||||
# Update history
|
||||
now = utc_now()
|
||||
history = await self.cog.data.RoleMenuHistory.table.update_where(
|
||||
menuid=self.data.menuid,
|
||||
roleid=role.id,
|
||||
userid=member.id,
|
||||
removed_at=None,
|
||||
).set(removed_at=now)
|
||||
await self.cog.cancel_expiring_tasks(*(row.equipid for row in history))
|
||||
|
||||
# Refund if required
|
||||
transactionids = [row['transactionid'] for row in history]
|
||||
if self.config.refunds.value and any(transactionids):
|
||||
transactionids = [tid for tid in transactionids if tid]
|
||||
economy: Economy = self.bot.get_cog('Economy')
|
||||
refunded = await economy.data.Transaction.refund_transactions(*transactionids)
|
||||
total_refund = sum(row.amount + row.bonus for row in refunded)
|
||||
else:
|
||||
total_refund = 0
|
||||
|
||||
# Ack the removal
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
title=t(_p(
|
||||
'rolemenu|deslect|success|title',
|
||||
"Role removed"
|
||||
))
|
||||
)
|
||||
if total_refund:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|deselect|success:refund|desc',
|
||||
"You have removed {role}, and been refunded {coin} **{amount}**."
|
||||
)).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=total_refund)
|
||||
else:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|deselect|success:norefund|desc',
|
||||
"You have unequipped {role}."
|
||||
)).format(role=role.mention)
|
||||
return embed
|
||||
else:
|
||||
# Member does not have the role, selection case.
|
||||
required = self.config.required_role.value
|
||||
if required is not None:
|
||||
# Check member has the required role
|
||||
if required not in member.roles:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:required_role',
|
||||
"You need to have the {role} role to use this!"
|
||||
)).format(role=required.mention)
|
||||
)
|
||||
|
||||
obtainable = self.config.obtainable.value
|
||||
if obtainable is not None:
|
||||
# Check shared roles
|
||||
menu_roleids = {mrole.data.roleid for mrole in self.roles}
|
||||
member_roleids = {role.id for role in member.roles}
|
||||
common = len(menu_roleids.intersection(member_roleids))
|
||||
if common >= obtainable:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:max_obtainable',
|
||||
"You already have the maximum of {obtainable} roles from this menu!"
|
||||
)).format(obtainable=obtainable)
|
||||
)
|
||||
|
||||
price = mrole.config.price.value
|
||||
if price:
|
||||
# Check member balance
|
||||
# TODO: More transaction safe (or rather check again after transaction)
|
||||
await lion.data.refresh()
|
||||
balance = lion.data.coins
|
||||
if balance < price:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:insufficient_funds',
|
||||
"The role {role} costs {coin}**{cost}**,"
|
||||
"but you only have {coin}**{balance}**!"
|
||||
)).format(
|
||||
role=role.mention,
|
||||
coin=self.bot.config.emojis.coin,
|
||||
cost=price,
|
||||
balance=balance,
|
||||
)
|
||||
)
|
||||
|
||||
conn = await self.bot.db.get_connection()
|
||||
async with conn.transaction():
|
||||
try:
|
||||
await member.add_roles(role)
|
||||
except discord.Forbidden:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:perms',
|
||||
"I don't have enough permissions to give you this role!"
|
||||
))
|
||||
)
|
||||
except discord.HTTPException:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:discord',
|
||||
"An unknown error occurred while assigning your role! "
|
||||
"Please try again later."
|
||||
))
|
||||
)
|
||||
|
||||
now = utc_now()
|
||||
|
||||
# Create transaction if applicable
|
||||
if price:
|
||||
economy: Economy = self.bot.get_cog('Economy')
|
||||
tx = await economy.data.Transaction.execute_transaction(
|
||||
transaction_type=TransactionType.OTHER,
|
||||
guildid=guild.id, actorid=member.id,
|
||||
from_account=member.id, to_account=None,
|
||||
amount=price
|
||||
)
|
||||
tid = tx.transactionid
|
||||
else:
|
||||
tid = None
|
||||
|
||||
# Calculate expiry
|
||||
duration = mrole.config.duration.value
|
||||
if duration is not None:
|
||||
expiry = now + dt.timedelta(seconds=duration)
|
||||
else:
|
||||
expiry = None
|
||||
|
||||
# Add to equip history
|
||||
equip = await self.cog.data.RoleMenuHistory.create(
|
||||
menuid=self.data.menuid, roleid=role.id,
|
||||
userid=member.id,
|
||||
obtained_at=now,
|
||||
transactionid=tid,
|
||||
expires_at=expiry
|
||||
)
|
||||
await self.cog.schedule_expiring(equip)
|
||||
|
||||
# Ack the selection
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_green(),
|
||||
title=t(_p(
|
||||
'rolemenu|select|success|title',
|
||||
"Role equipped"
|
||||
))
|
||||
)
|
||||
if price > 0:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|select|success:purchase|desc',
|
||||
"You have purchased the role {role} for {coin}**{amount}**"
|
||||
)).format(role=role.mention, coin=self.bot.config.emojis.coin, amount=price)
|
||||
else:
|
||||
embed.description = t(_p(
|
||||
'rolemenu|select|success:nopurchase|desc',
|
||||
"You have equipped the role {role}"
|
||||
)).format(role=role.mention)
|
||||
|
||||
if expiry is not None:
|
||||
embed.description += '\n' + t(_p(
|
||||
'rolemenu|select|expires_at',
|
||||
"The role will expire at {timestamp}."
|
||||
)).format(
|
||||
timestamp=discord.utils.format_dt(expiry)
|
||||
)
|
||||
return embed
|
||||
|
||||
async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int):
|
||||
"""
|
||||
Handle a component interaction callback for this menu.
|
||||
|
||||
Assumes the interaction has already been responded to (ephemerally).
|
||||
"""
|
||||
member = interaction.user
|
||||
guild = interaction.guild
|
||||
if not isinstance(member, discord.Member):
|
||||
# Occasionally Discord drops the ball on user type. This manually fetches the guild member.
|
||||
member = await guild.fetch_member(member.id)
|
||||
|
||||
# Localise to the member's locale
|
||||
lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member)
|
||||
ctx_locale.set(lion.private_locale(interaction))
|
||||
result = await self._handle_selection(lion, member, menuroleid)
|
||||
await interaction.edit_original_response(embed=result)
|
||||
|
||||
async def handle_reaction(self, reaction_payload: discord.RawReactionActionEvent):
|
||||
"""
|
||||
Handle a raw reaction event on a message the menu is attached to.
|
||||
|
||||
Ignores the event if it is not relevant.
|
||||
"""
|
||||
guild = self.bot.get_guild(reaction_payload.guild_id)
|
||||
channel = self.bot.get_channel(reaction_payload.channel_id)
|
||||
if guild and channel:
|
||||
emoji_map = self.emoji_map()
|
||||
menuroleid = emoji_map.get(reaction_payload.emoji, None)
|
||||
if menuroleid is not None:
|
||||
member = reaction_payload.member
|
||||
if not member:
|
||||
member = await guild.fetch_member(reaction_payload.user_id)
|
||||
if member.bot:
|
||||
return
|
||||
lion = await self.bot.core.lions.fetch_member(guild.id, member.id, member=member)
|
||||
ctx_locale.set(lion.private_locale())
|
||||
try:
|
||||
embed = await self._handle_selection(lion, member, menuroleid)
|
||||
except UserInputError as e:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=e.msg
|
||||
)
|
||||
t = self.bot.translator.t
|
||||
content = t(_p(
|
||||
'rolemenu|content:reactions',
|
||||
"[Click here]({jump_link}) to jump back."
|
||||
)).format(jump_link=jumpto(guild.id, channel.id, reaction_payload.message_id))
|
||||
try:
|
||||
await member.send(content=content, embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import discord
|
||||
|
||||
from settings import ModelData
|
||||
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
|
||||
from settings.setting_types import (
|
||||
RoleSetting, BoolSetting, StringSetting, DurationSetting
|
||||
RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting
|
||||
)
|
||||
from core.setting_types import CoinSetting
|
||||
from utils.ui import AButton, AsComponents
|
||||
from meta.errors import UserInputError
|
||||
from babel.translator import ctx_translator
|
||||
|
||||
from .data import RoleMenuData
|
||||
from . import babel
|
||||
@@ -59,26 +64,49 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.roleid.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'roleset:role|set_response:set',
|
||||
"This menu item will now give the role {role}."
|
||||
)).format(role=self.formatted)
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Label(ModelData, StringSetting):
|
||||
setting_id = 'role'
|
||||
setting_id = 'label'
|
||||
|
||||
_display_name = _p('roleset:label', "label")
|
||||
_desc = _p(
|
||||
'roleset:label|desc',
|
||||
"A short button label for this role."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'roleset:label|long_desc',
|
||||
"A short name for this role, to be displayed in button labels, dropdown titles, and some menu layouts. "
|
||||
"By default uses the Discord role name."
|
||||
)
|
||||
|
||||
_quote = False
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.label.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
resp = t(_p(
|
||||
'roleset:role|set_response',
|
||||
"This menu role is now called `{value}`."
|
||||
)).format(value=self.data)
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Emoji(ModelData, StringSetting):
|
||||
class Emoji(ModelData, EmojiSetting):
|
||||
setting_id = 'emoji'
|
||||
|
||||
_display_name = _p('roleset:emoji', "emoji")
|
||||
@@ -96,6 +124,56 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.emoji.name
|
||||
|
||||
@property
|
||||
def test_button(self):
|
||||
if self.data:
|
||||
button = AButton(emoji=self.data)
|
||||
button.disabled = True
|
||||
|
||||
@button
|
||||
async def emoji_test_callback(press, butt):
|
||||
await press.response.defer()
|
||||
else:
|
||||
button = None
|
||||
return button
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id, string: str, interaction: discord.Interaction = None, **kwargs):
|
||||
emojistr = await super()._parse_string(parent_id, string, interaction=interaction, **kwargs)
|
||||
if emojistr and interaction is not None:
|
||||
# Use the interaction to test
|
||||
button = AButton(emoji=emojistr)
|
||||
button.disabled = True
|
||||
view = AsComponents(button)
|
||||
try:
|
||||
await interaction.edit_original_response(
|
||||
content=f"Testing Emoji {emojistr}",
|
||||
view=view,
|
||||
)
|
||||
except discord.HTTPException:
|
||||
t = interaction.client.translator.t
|
||||
raise UserInputError(t(_p(
|
||||
'roleset:emoji|error:test_emoji',
|
||||
"The selected emoji `{emoji}` is invalid or has been deleted."
|
||||
)).format(emoji=emojistr))
|
||||
return emojistr
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'roleset:emoji|set_response:set',
|
||||
"The menu role emoji is now {emoji}."
|
||||
)).format(emoji=self.as_partial)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'roleset:emoji|set_response:unset',
|
||||
"The menu role emoji has been removed."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Description(ModelData, StringSetting):
|
||||
setting_id = 'description'
|
||||
@@ -105,15 +183,34 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
'roleset:description|desc',
|
||||
"A longer description of this role."
|
||||
)
|
||||
_accepts = _desc
|
||||
_long_desc = _p(
|
||||
'roleset:description|long_desc',
|
||||
"The description is displayed under the role label in dropdown style menus. "
|
||||
"It may also be used as a substitution key in custom role selection responses."
|
||||
)
|
||||
|
||||
_quote = False
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.description.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'roleset:description|set_response:set',
|
||||
"The role description has been set."
|
||||
))
|
||||
else:
|
||||
resp = t(_p(
|
||||
'roleset:description|set_response:unset',
|
||||
"The role description has been removed."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Price(ModelData, CoinSetting):
|
||||
setting_id = 'price'
|
||||
@@ -127,10 +224,30 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
'roleset:price|long_desc',
|
||||
"How much the role costs when selected, in LionCoins."
|
||||
)
|
||||
_accepts = _p(
|
||||
'roleset:price|accepts',
|
||||
"Amount of coins that the role costs."
|
||||
)
|
||||
_default = 0
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.price.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'roleset:price|set_response:set',
|
||||
"This role will now cost {price} to equip."
|
||||
)).format(price=self.formatted)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'roleset:price|set_response:unset',
|
||||
"This role will now be free to equip from this role menu."
|
||||
))
|
||||
return resp
|
||||
|
||||
@RoleMenuRoleConfig.register_model_setting
|
||||
class Duration(ModelData, DurationSetting):
|
||||
setting_id = 'duration'
|
||||
@@ -145,5 +262,26 @@ class RoleMenuRoleOptions(SettingGroup):
|
||||
"Allows creation of 'temporary roles' which expire a given time after being equipped. "
|
||||
"Refunds will not be given upon expiry."
|
||||
)
|
||||
_notset_str = _p(
|
||||
'roleset:duration|notset',
|
||||
"Forever."
|
||||
)
|
||||
|
||||
_model = RoleMenuData.RoleMenuRole
|
||||
_column = RoleMenuData.RoleMenuRole.duration.name
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
t = ctx_translator.get().t
|
||||
value = self.value
|
||||
if value:
|
||||
resp = t(_p(
|
||||
'roleset:duration|set_response:set',
|
||||
"This role will now expire after {duration}."
|
||||
)).format(duration=self.formatted)
|
||||
else:
|
||||
resp = t(_p(
|
||||
'roleset:duration|set_response:unset',
|
||||
"This role will no longer expire after being selected."
|
||||
))
|
||||
return resp
|
||||
|
||||
@@ -13,7 +13,7 @@ _p = babel._p
|
||||
DEFAULT_EMOJI = '🔲'
|
||||
|
||||
|
||||
templates = {}
|
||||
templates: dict[int, 'Template'] = {}
|
||||
|
||||
|
||||
class Template:
|
||||
@@ -68,7 +68,7 @@ async def simple_template(menu) -> MessageArgs:
|
||||
duration = menurole.config.duration
|
||||
|
||||
if emoji.data:
|
||||
parts.append(emoji.formatted)
|
||||
parts.append(emoji.data)
|
||||
|
||||
parts.append(role.formatted)
|
||||
|
||||
@@ -114,7 +114,7 @@ async def twocolumn_template(menu) -> MessageArgs:
|
||||
)
|
||||
for block in blocks:
|
||||
block_lines = [
|
||||
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
|
||||
f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**"
|
||||
for menurole in block
|
||||
]
|
||||
if block_lines:
|
||||
@@ -151,7 +151,7 @@ async def threecolumn_template(menu) -> MessageArgs:
|
||||
)
|
||||
for block in blocks:
|
||||
block_lines = [
|
||||
f"{menurole.config.emoji.formatted or DEFAULT_EMOJI} {menurole.config.label.formatted}"
|
||||
f"{menurole.config.emoji.data or ' '} **{menurole.config.label.formatted}**"
|
||||
for menurole in block
|
||||
]
|
||||
if block_lines:
|
||||
@@ -188,7 +188,7 @@ async def shop_template(menu) -> MessageArgs:
|
||||
parts.append("|")
|
||||
|
||||
if emoji.data:
|
||||
parts.append(emoji.formatted)
|
||||
parts.append(emoji.data)
|
||||
|
||||
parts.append(role.formatted)
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ from discord.ui.button import button, Button, ButtonStyle
|
||||
from discord.ui.select import select, Select, RoleSelect, ChannelSelect, SelectOption
|
||||
|
||||
from meta import LionBot, conf
|
||||
from meta.errors import UserInputError
|
||||
from utils.lib import utc_now, MessageArgs, error_embed
|
||||
from utils.ui import MessageUI, ConfigEditor, FastModal, error_handler_for, ModalRetryUI, MsgEditor
|
||||
from meta.errors import UserInputError, ResponseTimedOut, SafeCancellation
|
||||
from utils.lib import utc_now, MessageArgs, error_embed, tabulate
|
||||
from utils.ui import (
|
||||
MessageUI, ConfigEditor, FastModal, error_handler_for,
|
||||
ModalRetryUI, MsgEditor, Confirm, HookedItem, AsComponents,
|
||||
)
|
||||
from babel.translator import ctx_locale
|
||||
from wards import equippable_role
|
||||
|
||||
@@ -32,6 +35,10 @@ class RoleEditorInput(FastModal):
|
||||
await ModalRetryUI(self, error.msg).respond_to(interaction)
|
||||
|
||||
|
||||
class AChannelSelect(HookedItem, ChannelSelect):
|
||||
...
|
||||
|
||||
|
||||
class EditorMode(Enum):
|
||||
OPTIONS = 0
|
||||
ROLES = 1
|
||||
@@ -39,6 +46,8 @@ class EditorMode(Enum):
|
||||
|
||||
|
||||
class MenuEditor(MessageUI):
|
||||
_listening = {} # (channelid, callerid) -> active MenuEditor
|
||||
|
||||
def _init_children(self):
|
||||
# HACK to stop ViewWeights complaining that this UI has too many children
|
||||
# Children will be correctly initialised after parent init.
|
||||
@@ -51,40 +60,76 @@ class MenuEditor(MessageUI):
|
||||
self.bot = bot
|
||||
self.menu = menu
|
||||
self.data: RoleMenuData = bot.get_cog('RoleMenuCog').data
|
||||
self.listen_key = None
|
||||
|
||||
# UI State
|
||||
self.mode: EditorMode = EditorMode.ROLES
|
||||
self.page_count: int = 1
|
||||
self.pagen: int = 0
|
||||
self.page_block: list[RoleMenuRole] = []
|
||||
self._preview: Optional[discord.Interaction] = None
|
||||
|
||||
# ----- UI API -----
|
||||
async def dispatch_update(self):
|
||||
async def update_preview(self):
|
||||
"""
|
||||
Broadcast that the menu has changed.
|
||||
|
||||
This updates the preview, and tells the menu itself to update any linked messages.
|
||||
Update the preview message if it exists.
|
||||
"""
|
||||
await self.menu.reload()
|
||||
if self._preview is not None:
|
||||
args = await self._preview_args()
|
||||
args = await self.menu.make_args()
|
||||
view = await self.menu.make_view()
|
||||
try:
|
||||
await self._preview.edit_original_response(**args.edit_args)
|
||||
await self._preview.edit_original_response(**args.edit_args, view=view)
|
||||
except discord.NotFound:
|
||||
self._preview = None
|
||||
except discord.HTTPException as e:
|
||||
# Due to emoji validation on creation and message edit validation,
|
||||
# This should be very rare.
|
||||
# Might happen if e.g. a custom emoji is deleted between opening the editor
|
||||
# and showing the preview.
|
||||
# Just show the error to the user and let them deal with it or rerun the editor.
|
||||
t = self.bot.translator.t
|
||||
title = t(_p(
|
||||
'ui:menu_editor|preview|error:title',
|
||||
"Display Error!"
|
||||
))
|
||||
desc = t(_p(
|
||||
'ui:menu_editor|preview|error:desc',
|
||||
"Failed to display preview!\n"
|
||||
"**Error:** `{exception}`"
|
||||
)).format(
|
||||
exception=e.text
|
||||
)
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=title,
|
||||
description=desc
|
||||
)
|
||||
try:
|
||||
await self._preview.edit_original_response(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# If we can't even edit the preview message now, something is probably wrong with the connection
|
||||
# Just silently ignore
|
||||
pass
|
||||
|
||||
async def _preview_args(self):
|
||||
if (tid := self.menu.data.templateid) is not None:
|
||||
# Apply template
|
||||
template = templates[tid]
|
||||
args = await template.render_menu(self.menu)
|
||||
else:
|
||||
raw = self.menu.data.rawmessage
|
||||
data = json.loads(raw)
|
||||
args = MessageArgs(
|
||||
content=data.get('content', ''),
|
||||
embed=discord.Embed.from_dict(data['embed'])
|
||||
)
|
||||
return args
|
||||
async def cleanup(self):
|
||||
self._listening.pop(self.listen_key, None)
|
||||
await super().cleanup()
|
||||
|
||||
async def run(self, interaction: discord.Interaction, **kwargs):
|
||||
self.listen_key = (interaction.channel.id, interaction.user.id, self.menu.data.menuid)
|
||||
existing = self._listening.get(self.listen_key, None)
|
||||
if existing:
|
||||
await existing.quit()
|
||||
self._listening[self.listen_key] = self
|
||||
await super().run(interaction, **kwargs)
|
||||
|
||||
async def quit(self):
|
||||
if self._preview is not None and not self._preview.is_expired():
|
||||
try:
|
||||
await self._preview.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await super().quit()
|
||||
|
||||
# ----- Components -----
|
||||
# -- Options Components --
|
||||
@@ -154,8 +199,6 @@ class MenuEditor(MessageUI):
|
||||
# Write settings
|
||||
for instance in modified:
|
||||
await instance.write()
|
||||
# Propagate an update
|
||||
await self.dispatch_update()
|
||||
# Refresh the UI
|
||||
await self.refresh(thinking=interaction)
|
||||
else:
|
||||
@@ -182,7 +225,6 @@ class MenuEditor(MessageUI):
|
||||
instance = self.menu.config.sticky
|
||||
instance.value = not instance.value
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def sticky_button_refresh(self):
|
||||
@@ -207,7 +249,6 @@ class MenuEditor(MessageUI):
|
||||
instance = self.menu.config.refunds
|
||||
instance.value = not instance.value
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def refunds_button_refresh(self):
|
||||
@@ -215,7 +256,7 @@ class MenuEditor(MessageUI):
|
||||
button = self.refunds_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:refunds|label',
|
||||
"Refunds"
|
||||
"Toggle Refunds"
|
||||
))
|
||||
if self.menu.config.refunds.value:
|
||||
button.style = ButtonStyle.blurple
|
||||
@@ -238,7 +279,6 @@ class MenuEditor(MessageUI):
|
||||
instance = self.menu.config.required_role
|
||||
instance.data = new_data
|
||||
await instance.write()
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
|
||||
async def reqroles_menu_refresh(self):
|
||||
@@ -300,6 +340,7 @@ class MenuEditor(MessageUI):
|
||||
|
||||
@modal.submit_callback()
|
||||
async def save_options(interaction: discord.Interaction):
|
||||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||
modified = []
|
||||
for instance, field, original in zip(instances, fields, originals):
|
||||
if field.value != original:
|
||||
@@ -308,25 +349,32 @@ class MenuEditor(MessageUI):
|
||||
if not userstr:
|
||||
new_data = None
|
||||
else:
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr)
|
||||
new_data = await instance._parse_string(instance.parent_id, userstr, interaction=interaction)
|
||||
instance.data = new_data
|
||||
modified.append(instance)
|
||||
if modified:
|
||||
# All fields have been parsed, it is safe to respond
|
||||
await interaction.response.defer(thinking=True, ephemeral=True)
|
||||
# Write settings
|
||||
for instance in modified:
|
||||
await instance.write()
|
||||
# Propagate an update
|
||||
await self.dispatch_update()
|
||||
# Refresh the UI
|
||||
await self.refresh(thinking=interaction)
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
if self.menu.data.menutype is MenuType.REACTION:
|
||||
try:
|
||||
await self.menu.update_reactons()
|
||||
except SafeCancellation as e:
|
||||
await interaction.followup.send(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=e.msg
|
||||
),
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
# Nothing was modified, quietly accept
|
||||
await interaction.response.defer(thinking=False)
|
||||
await interaction.delete_original_response()
|
||||
|
||||
await interaction.response.send_modal(modal)
|
||||
await self.dispatch_update()
|
||||
|
||||
# Add Roles Menu
|
||||
@select(cls=RoleSelect, placeholder="ADD_ROLES_MENU_PLACEHOLDER", min_values=0, max_values=25)
|
||||
@@ -369,21 +417,35 @@ class MenuEditor(MessageUI):
|
||||
)))
|
||||
|
||||
# Create roles
|
||||
# TODO: Emoji generation
|
||||
emojis = self.menu.unused_emojis(include_defaults=(self.menu.data.menutype is MenuType.REACTION))
|
||||
rows = await self.data.RoleMenuRole.table.insert_many(
|
||||
('menuid', 'roleid', 'label'),
|
||||
*((self.menu.data.menuid, role.id, role.name[:100]) for role in to_create.values())
|
||||
('menuid', 'roleid', 'label', 'emoji'),
|
||||
*(
|
||||
(self.menu.data.menuid, role.id, role.name[:100], next(emojis, None))
|
||||
for role in to_create.values()
|
||||
)
|
||||
).with_adapter(self.data.RoleMenuRole._make_rows)
|
||||
mroles = [RoleMenuRole(self.bot, row) for row in rows]
|
||||
single = single if single is not None else mroles[0]
|
||||
await self.dispatch_update()
|
||||
|
||||
if len(roles) == 1:
|
||||
await self._edit_menu_role(selection, single)
|
||||
await self.refresh()
|
||||
else:
|
||||
await selection.response.defer()
|
||||
await self.refresh()
|
||||
|
||||
await self.menu.reload_roles()
|
||||
if self.menu.data.name == 'Untitled':
|
||||
# Hack to name an anonymous menu
|
||||
# TODO: Formalise this
|
||||
await self.menu.data.update(name=roles[0].name)
|
||||
await self.refresh()
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
if self.menu.data.menutype is MenuType.REACTION:
|
||||
try:
|
||||
await self.menu.update_reactons()
|
||||
except SafeCancellation as e:
|
||||
raise UserInputError(e.msg)
|
||||
|
||||
async def add_roles_menu_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
@@ -395,6 +457,7 @@ class MenuEditor(MessageUI):
|
||||
|
||||
def _role_option(self, menurole: RoleMenuRole):
|
||||
return SelectOption(
|
||||
emoji=menurole.config.emoji.data or None,
|
||||
label=menurole.config.label.value,
|
||||
value=str(menurole.data.menuroleid),
|
||||
description=menurole.config.description.value,
|
||||
@@ -435,7 +498,11 @@ class MenuEditor(MessageUI):
|
||||
if menuroleids:
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
await self.data.RoleMenuRole.table.delete_where(menuroleid=menuroleids)
|
||||
await self.dispatch_update()
|
||||
|
||||
await self.menu.reload_roles()
|
||||
await self.refresh(thinking=selection)
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
await selection.response.defer(thinking=False)
|
||||
|
||||
@@ -468,7 +535,7 @@ class MenuEditor(MessageUI):
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'ui:menu_editor|button:style|error:non-managed',
|
||||
"Cannot change the style of a menu attached to a message I did not send! Please RePost first."
|
||||
"Cannot change the style of a menu attached to a message I did not send! Please repost first."
|
||||
))
|
||||
)
|
||||
|
||||
@@ -495,8 +562,8 @@ class MenuEditor(MessageUI):
|
||||
Select one of Reaction Roles / Dropdown / Button
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
value = int(selected.values[0])
|
||||
menutype = MenuType(value)
|
||||
value = selected.values[0]
|
||||
menutype = MenuType[value]
|
||||
if menutype is not self.menu.data.menutype:
|
||||
# A change is requested
|
||||
if menutype is MenuType.REACTION:
|
||||
@@ -521,8 +588,9 @@ class MenuEditor(MessageUI):
|
||||
)
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
await self.menu.data.update(menutype=menutype)
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
await selection.response.defer()
|
||||
|
||||
@@ -540,7 +608,7 @@ class MenuEditor(MessageUI):
|
||||
'ui:menu_editor|menu:style|option:reaction|desc',
|
||||
"Roles are represented compactly as clickable reactions on a message."
|
||||
)),
|
||||
value=str(MenuType.REACTION.value),
|
||||
value=str(MenuType.REACTION.name),
|
||||
default=(self.menu.data.menutype is MenuType.REACTION)
|
||||
),
|
||||
SelectOption(
|
||||
@@ -549,7 +617,7 @@ class MenuEditor(MessageUI):
|
||||
'ui:menu_editor|menu:style|option:button|desc',
|
||||
"Roles are represented in 5 rows of 5 buttons, each with an emoji and label."
|
||||
)),
|
||||
value=str(MenuType.BUTTON.value),
|
||||
value=str(MenuType.BUTTON.name),
|
||||
default=(self.menu.data.menutype is MenuType.BUTTON)
|
||||
),
|
||||
SelectOption(
|
||||
@@ -558,7 +626,7 @@ class MenuEditor(MessageUI):
|
||||
'ui:menu_editor|menu:style|option:dropdown|desc',
|
||||
"Roles are selectable from a dropdown menu below the message."
|
||||
)),
|
||||
value=str(MenuType.DROPDOWN.value),
|
||||
value=str(MenuType.DROPDOWN.name),
|
||||
default=(self.menu.data.menutype is MenuType.DROPDOWN)
|
||||
)
|
||||
]
|
||||
@@ -566,10 +634,12 @@ class MenuEditor(MessageUI):
|
||||
async def _editor_callback(self, new_data):
|
||||
raws = json.dumps(new_data)
|
||||
await self.menu.data.update(rawmessage=raws)
|
||||
await self.dispatch_update()
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
|
||||
async def _message_editor(self, interaction: discord.Interaction):
|
||||
# Spawn the message editor with the current rawmessage data.
|
||||
# If the rawmessage data is empty, use the current template instead.
|
||||
editor = MsgEditor(
|
||||
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
|
||||
)
|
||||
@@ -608,12 +678,14 @@ class MenuEditor(MessageUI):
|
||||
|
||||
# Spawn editor
|
||||
await self._message_editor(selection)
|
||||
await self.dispatch_update()
|
||||
await self.refresh()
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
await self.menu.data.update(templateid=templateid)
|
||||
await self.dispatch_update()
|
||||
await self.refresh(thinking=selection)
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
await selection.response.defer()
|
||||
|
||||
@@ -646,24 +718,122 @@ class MenuEditor(MessageUI):
|
||||
|
||||
# -- Common Components --
|
||||
# Delete Menu Button
|
||||
# Quit Button
|
||||
@button(label="DELETE_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
|
||||
async def delete_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Confirm menu deletion, and delete.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
confirm_msg = t(_p(
|
||||
'ui:menu_editor|button:delete|confirm|title',
|
||||
"Are you sure you want to delete this menu? This is not reversible!"
|
||||
))
|
||||
confirm = Confirm(confirm_msg, self._callerid)
|
||||
confirm.confirm_button.label = t(_p(
|
||||
'ui:menu_editor|button:delete|confirm|button:yes',
|
||||
"Yes, Delete Now"
|
||||
))
|
||||
confirm.confirm_button.style = ButtonStyle.red
|
||||
confirm.cancel_button.label = t(_p(
|
||||
'ui:menu_editor|button:delete|confirm|button:no',
|
||||
"No, Go Back"
|
||||
))
|
||||
confirm.cancel_button.style = ButtonStyle.green
|
||||
try:
|
||||
result = await confirm.ask(press, ephemeral=True)
|
||||
except ResponseTimedOut:
|
||||
result = False
|
||||
|
||||
if result:
|
||||
await self.menu.delete()
|
||||
await self.quit()
|
||||
|
||||
async def delete_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.delete_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:delete|label',
|
||||
"Delete Menu"
|
||||
))
|
||||
|
||||
# Quit Button
|
||||
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
|
||||
async def quit_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Close the UI. This should also close all children.
|
||||
"""
|
||||
await press.response.defer(thinking=False)
|
||||
await self.quit()
|
||||
|
||||
# Page Buttons
|
||||
@button(emoji=conf.emojis.forward)
|
||||
async def next_page_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer()
|
||||
self.pagen += 1
|
||||
await self.refresh()
|
||||
|
||||
@button(emoji=conf.emojis.backward)
|
||||
async def prev_page_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer()
|
||||
self.pagen -= 1
|
||||
await self.refresh()
|
||||
|
||||
# Page left Button
|
||||
# Edit Message Button
|
||||
@button(label="EDIT_MSG_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def edit_msg_button(self, press: discord.Interaction, pressed: Button):
|
||||
# Set the templateid to None if it isn't already
|
||||
# And initialise the rawmessage if it needs it.
|
||||
if (templateid := self.menu.data.templateid) is not None:
|
||||
update_args = {'templateid': None}
|
||||
if not self.menu.data.rawmessage:
|
||||
template = templates[templateid]
|
||||
margs = await template.render_menu(self.menu)
|
||||
raw = {
|
||||
'content': margs.kwargs.get('content', ''),
|
||||
}
|
||||
if 'embed' in margs.kwargs:
|
||||
raw['embed'] = margs.kwargs['embed'].to_dict()
|
||||
rawjson = json.dumps(raw)
|
||||
update_args['rawmessage'] = rawjson
|
||||
|
||||
await self.menu.data.update(**update_args)
|
||||
|
||||
# At this point we are certain the menu is in custom mode and has a rawmessage
|
||||
# Spawn editor
|
||||
await self._message_editor(press)
|
||||
await self.refresh()
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
|
||||
async def edit_msg_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.edit_msg_button
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:edit_msg|label',
|
||||
"Edit Message"
|
||||
))
|
||||
# Disable the button if we are on a non-managed message
|
||||
button.disabled = not self.menu.managed
|
||||
|
||||
# Preview Button
|
||||
@button(label="PREVIEW_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def preview_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Display or update the preview message.
|
||||
"""
|
||||
args = await self._preview_args()
|
||||
args = await self.menu.make_args()
|
||||
view = await self.menu.make_view()
|
||||
if self._preview is not None:
|
||||
try:
|
||||
await self._preview.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
self._preview = None
|
||||
await press.response.send_message(**args.send_args, ephemeral=True)
|
||||
await press.response.send_message(
|
||||
**args.send_args,
|
||||
view=view or discord.utils.MISSING,
|
||||
ephemeral=True
|
||||
)
|
||||
self._preview = press
|
||||
|
||||
async def preview_button_refresh(self):
|
||||
@@ -675,25 +845,237 @@ class MenuEditor(MessageUI):
|
||||
))
|
||||
|
||||
# Repost Menu Button
|
||||
@button(label="REPOST_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
async def repost_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Repost the menu in a selected channel.
|
||||
|
||||
Pops up a minimal channel selection UI, asking where they want to post it.
|
||||
"""
|
||||
t = self.bot.translator.t
|
||||
|
||||
@AChannelSelect(
|
||||
placeholder=t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|menu:channel|placeholder',
|
||||
"Select New Channel"
|
||||
)),
|
||||
channel_types=[discord.ChannelType.text, discord.ChannelType.voice],
|
||||
min_values=1, max_values=1
|
||||
)
|
||||
async def repost_widget(selection: discord.Interaction, selected: ChannelSelect):
|
||||
channel = selected.values[0].resolve() if selected.values else None
|
||||
if channel is None:
|
||||
await selection.response.defer()
|
||||
else:
|
||||
# Valid channel selected, do the repost
|
||||
await selection.response.defer(thinking=True, ephemeral=True)
|
||||
|
||||
try:
|
||||
await self.menu.repost_to(channel)
|
||||
except discord.Forbidden:
|
||||
title = t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|error:perms|title',
|
||||
"Insufficient Permissions!"
|
||||
))
|
||||
desc = t(_p(
|
||||
'ui:menu_editor|button:repost|eidget:repost|error:perms|desc',
|
||||
"I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission in this channel."
|
||||
))
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=title,
|
||||
description=desc
|
||||
)
|
||||
await selection.edit_original_response(embed=embed)
|
||||
except discord.HTTPException:
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|error:post_failed',
|
||||
"An error ocurred while posting to {channel}. Do I have sufficient permissions?"
|
||||
)).format(channel=channel.mention)
|
||||
)
|
||||
await selection.edit_original_response(embed=error)
|
||||
else:
|
||||
try:
|
||||
await press.delete_original_response()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
success_title = t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|success|title',
|
||||
"Role Menu Moved"
|
||||
))
|
||||
desc_lines = []
|
||||
desc_lines.append(
|
||||
t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|success|desc:general',
|
||||
"The role menu `{name}` is now available at {message_link}."
|
||||
)).format(
|
||||
name=self.menu.data.name,
|
||||
message_link=self.menu.message.jump_url,
|
||||
)
|
||||
)
|
||||
if self.menu.data.menutype is MenuType.REACTION:
|
||||
try:
|
||||
await self.menu.update_reactons()
|
||||
except SafeCancellation as e:
|
||||
desc_lines.append(e.msg)
|
||||
else:
|
||||
t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|success|desc:reactions',
|
||||
"Please check the message reactions are correct."
|
||||
))
|
||||
await selection.edit_original_response(
|
||||
embed=discord.Embed(
|
||||
title=success_title,
|
||||
description='\n'.join(desc_lines),
|
||||
colour=discord.Colour.brand_green(),
|
||||
)
|
||||
)
|
||||
|
||||
# Create the selection embed
|
||||
title = t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|title',
|
||||
"Repost Role Menu"
|
||||
))
|
||||
desc = t(_p(
|
||||
'ui:menu_editor|button:repost|widget:repost|description',
|
||||
"Please select the channel to which you want to resend this menu."
|
||||
))
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=title, description=desc
|
||||
)
|
||||
# Send as response with the repost widget attached
|
||||
await press.response.send_message(embed=embed, view=AsComponents(repost_widget))
|
||||
|
||||
async def repost_button_refresh(self):
|
||||
t = self.bot.translator.t
|
||||
button = self.repost_button
|
||||
if self.menu.message is not None:
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:repost|label:repost',
|
||||
"Repost"
|
||||
))
|
||||
else:
|
||||
button.label = t(_p(
|
||||
'ui:menu_editor|button:repost|label:post',
|
||||
"Post"
|
||||
))
|
||||
|
||||
# ----- UI Flow -----
|
||||
async def make_message(self) -> MessageArgs:
|
||||
t = self.bot.translator.t
|
||||
# TODO: Link to actual message
|
||||
|
||||
title = t(_p(
|
||||
'ui:menu_editor|embed|title',
|
||||
"'{name}' Role Menu Editor"
|
||||
"Role Menu Editor"
|
||||
)).format(name=self.menu.config.name.value)
|
||||
|
||||
table = await RoleMenuOptions().make_setting_table(self.menu.data.menuid)
|
||||
|
||||
jump = self.menu.jump_link
|
||||
if jump:
|
||||
jump_text = t(_p(
|
||||
'ui:menu_editor|embed|description|jump_text:attached',
|
||||
"Members may use this menu from {jump_url}"
|
||||
)).format(jump_url=jump)
|
||||
else:
|
||||
jump_text = t(_p(
|
||||
'ui:menu_editor|embed|description|jump_text:unattached',
|
||||
"This menu is not currently active!\n"
|
||||
"Make it available by clicking `Post` below."
|
||||
))
|
||||
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=title,
|
||||
description=table
|
||||
description=jump_text + '\n' + table
|
||||
)
|
||||
# Tip field
|
||||
embed.add_field(
|
||||
inline=False,
|
||||
name=t(_p(
|
||||
'ui:menu_editor|embed|field:tips|name',
|
||||
"Command Tips"
|
||||
)),
|
||||
value=t(_p(
|
||||
'ui:menu_editor|embed|field:tips|value',
|
||||
"Use the following commands for faster menu setup.\n"
|
||||
"{menuedit} to edit the above menu options.\n"
|
||||
"{addrole} to add new roles (recommended for roles with emojis).\n"
|
||||
"{editrole} to edit role options."
|
||||
)).format(
|
||||
menuedit=self.bot.core.mention_cmd('rolemenu editmenu'),
|
||||
addrole=self.bot.core.mention_cmd('rolemenu addrole'),
|
||||
editrole=self.bot.core.mention_cmd('rolemenu editrole'),
|
||||
)
|
||||
)
|
||||
|
||||
# Compute and add the pages
|
||||
for mrole in self.page_block:
|
||||
name = f"{mrole.config.label.formatted}"
|
||||
prop_map = {
|
||||
mrole.config.emoji.display_name: mrole.config.emoji.formatted,
|
||||
mrole.config.price.display_name: mrole.config.price.formatted,
|
||||
mrole.config.duration.display_name: mrole.config.duration.formatted,
|
||||
mrole.config.description.display_name: mrole.config.description.formatted,
|
||||
}
|
||||
prop_table = '\n'.join(tabulate(*prop_map.items()))
|
||||
value = f"{mrole.config.role.formatted}\n{prop_table}"
|
||||
|
||||
embed.add_field(name=name, value=value, inline=True)
|
||||
|
||||
return MessageArgs(embed=embed)
|
||||
|
||||
async def _handle_invalid_emoji(self, error: discord.HTTPException):
|
||||
t = self.bot.translator.t
|
||||
|
||||
text = error.text
|
||||
splits = text.split('.')
|
||||
i = splits.index('emoji')
|
||||
role_index = int(splits[i-1])
|
||||
mrole = self.menu.roles[role_index]
|
||||
|
||||
error = discord.Embed(
|
||||
colour=discord.Colour.brand_red(),
|
||||
title=t(_p(
|
||||
'ui:menu_editor|error:invald_emoji|title',
|
||||
"Invalid emoji encountered."
|
||||
)),
|
||||
description=t(_p(
|
||||
'ui:menu_editor|error:invalid_emoji|desc',
|
||||
"The emoji `{emoji}` for menu role `{label}` no longer exists, unsetting."
|
||||
)).format(emoji=mrole.config.emoji.data, label=mrole.config.label.data)
|
||||
)
|
||||
await mrole.data.update(emoji=None)
|
||||
await self.channel.send(embed=error)
|
||||
|
||||
async def _redraw(self, args):
|
||||
try:
|
||||
await super()._redraw(args)
|
||||
except discord.HTTPException as e:
|
||||
if e.code == 50035 and 'Invalid emoji' in e.text:
|
||||
await self._handle_invalid_emoji(e)
|
||||
await self.refresh()
|
||||
await self.update_preview()
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
raise e
|
||||
|
||||
async def draw(self, *args, **kwargs):
|
||||
try:
|
||||
await super().draw(*args, **kwargs)
|
||||
except discord.HTTPException as e:
|
||||
if e.code == 50035 and 'Invalid emoji' in e.text:
|
||||
await self._handle_invalid_emoji(e)
|
||||
await self.draw(*args, **kwargs)
|
||||
await self.menu.update_message()
|
||||
else:
|
||||
raise e
|
||||
|
||||
async def refresh_layout(self):
|
||||
to_refresh = (
|
||||
self.options_button_refresh(),
|
||||
@@ -709,15 +1091,20 @@ class MenuEditor(MessageUI):
|
||||
self.style_menu_refresh(),
|
||||
self.template_menu_refresh(),
|
||||
self.preview_button_refresh(),
|
||||
self.delete_button_refresh(),
|
||||
self.edit_msg_button_refresh(),
|
||||
self.repost_button_refresh(),
|
||||
)
|
||||
await asyncio.gather(*to_refresh)
|
||||
|
||||
line_1 = (
|
||||
self.options_button, self.modify_roles_button, self.style_button,
|
||||
)
|
||||
line_last = (
|
||||
self.preview_button,
|
||||
self.options_button, self.modify_roles_button, self.style_button, self.delete_button, self.quit_button
|
||||
)
|
||||
line_1 = (
|
||||
self.preview_button, self.edit_msg_button, self.repost_button,
|
||||
)
|
||||
if self.page_count > 1:
|
||||
line_1 = (self.prev_page_button, *line_last, self.next_page_button)
|
||||
if self.mode is EditorMode.OPTIONS:
|
||||
self.set_layout(
|
||||
line_1,
|
||||
@@ -742,4 +1129,10 @@ class MenuEditor(MessageUI):
|
||||
)
|
||||
|
||||
async def reload(self):
|
||||
...
|
||||
mroles = self.menu.roles
|
||||
page_size = 6
|
||||
blocks = [mroles[i:i+page_size] for i in range(0, len(mroles), page_size)] or [[]]
|
||||
self.page_count = len(blocks)
|
||||
self.pagen = self.pagen % self.page_count
|
||||
self.page_block = blocks[self.pagen]
|
||||
await self.menu.fetch_message()
|
||||
|
||||
@@ -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 [[]]
|
||||
|
||||
@@ -105,6 +105,116 @@ class StringSetting(InteractiveSetting[ParentID, str, str]):
|
||||
return None
|
||||
|
||||
|
||||
class EmojiSetting(InteractiveSetting[ParentID, str, str]):
|
||||
"""
|
||||
Setting type representing a stored emoji.
|
||||
|
||||
The emoji is stored in a single string field, and at no time is guaranteed to be a valid emoji.
|
||||
"""
|
||||
_accepts = _p('settype:emoji|accepts', "Paste a builtin emoji, custom emoji, or emoji id.")
|
||||
|
||||
@property
|
||||
def input_formatted(self) -> str:
|
||||
"""
|
||||
Return the current data string.
|
||||
"""
|
||||
if self._data is not None:
|
||||
return str(self._data)
|
||||
else:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, parent_id: ParentID, value, **kwargs):
|
||||
"""
|
||||
Return the provided value string as the data string.
|
||||
"""
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
"""
|
||||
Return the provided data string as the value string.
|
||||
"""
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
async def _parse_string(cls, parent_id, string: str, **kwargs):
|
||||
"""
|
||||
Parse the given user entered emoji string.
|
||||
|
||||
Accepts unicode (builtin) emojis, custom emojis, and custom emoji ids.
|
||||
"""
|
||||
t = ctx_translator.get().t
|
||||
|
||||
provided = string
|
||||
string = string.strip(' :<>')
|
||||
if string.startswith('a:'):
|
||||
string = string[2:]
|
||||
|
||||
if not string or string.lower() == 'none':
|
||||
emojistr = None
|
||||
elif string.isdigit():
|
||||
# Assume emoji id
|
||||
emojistr = f"<a:unknown:{string}>"
|
||||
elif ':' in string:
|
||||
# Assume custom emoji
|
||||
emojistr = provided.strip()
|
||||
elif string.isascii():
|
||||
# Probably not an emoji
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'settype:emoji|error:parse',
|
||||
"Could not parse `{provided}` as a Discord emoji. "
|
||||
"Supported formats are builtin emojis (e.g. `{builtin}`), "
|
||||
"custom emojis (e.g. {custom}), "
|
||||
"or custom emoji ids (e.g. `{custom_id}`)."
|
||||
)).format(
|
||||
provided=provided,
|
||||
builtin="🤔",
|
||||
custom="*`<`*`CuteLeo:942499177135480942`*`>`*",
|
||||
custom_id="942499177135480942",
|
||||
)
|
||||
)
|
||||
else:
|
||||
# We don't have a good way of testing for emoji unicode
|
||||
# So just assume anything with unicode is an emoji.
|
||||
emojistr = string
|
||||
|
||||
return emojistr
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, parent_id, data, **kwargs):
|
||||
"""
|
||||
Optionally (see `_quote`) wrap the data string in backticks.
|
||||
"""
|
||||
if data:
|
||||
return data
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def as_partial(self) -> Optional[discord.PartialEmoji]:
|
||||
return self._parse_emoji(self.data)
|
||||
|
||||
@staticmethod
|
||||
def _parse_emoji(emojistr: str):
|
||||
"""
|
||||
Converts a provided string into a PartialEmoji.
|
||||
Deos not validate the emoji string.
|
||||
"""
|
||||
if not emojistr:
|
||||
return None
|
||||
elif ":" in emojistr:
|
||||
emojistr = emojistr.strip('<>')
|
||||
splits = emojistr.split(":")
|
||||
if len(splits) == 3:
|
||||
animated, name, id = splits
|
||||
animated = bool(animated)
|
||||
return discord.PartialEmoji(name=name, animated=animated, id=int(id))
|
||||
else:
|
||||
return discord.PartialEmoji(name=emojistr)
|
||||
|
||||
|
||||
CT = TypeVar('CT', 'GuildChannel', 'discord.Object', 'discord.Thread')
|
||||
MCT = TypeVar('MCT', discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.Object)
|
||||
|
||||
@@ -558,7 +668,7 @@ class IntegerSetting(InteractiveSetting[ParentID, int, int]):
|
||||
return f"`{data}`"
|
||||
|
||||
|
||||
class EmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]):
|
||||
class PartialEmojiSetting(InteractiveSetting[ParentID, str, discord.PartialEmoji]):
|
||||
"""
|
||||
Setting type mixin describing an Emoji string.
|
||||
|
||||
@@ -1140,15 +1250,6 @@ class DurationSetting(InteractiveSetting[ParentID, int, int]):
|
||||
return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days))
|
||||
|
||||
|
||||
class MessageSetting(StringSetting):
|
||||
"""
|
||||
Typed Setting ABC representing a message sent to Discord.
|
||||
|
||||
Placeholder implemented as a StringSetting until Context is built.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ListSetting:
|
||||
"""
|
||||
Mixin to implement a setting type representing a list of existing settings.
|
||||
|
||||
@@ -150,7 +150,7 @@ class TextTrackerCog(LionCog):
|
||||
)
|
||||
|
||||
# Batch-fetch lguilds
|
||||
lguilds = await self.bot.core.lions.fetch_guilds(*(session.guildid for session in batch))
|
||||
lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch})
|
||||
|
||||
# Build data
|
||||
rows = []
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from contextvars import Context
|
||||
|
||||
import discord
|
||||
from discord.partial_emoji import _EmojiTag
|
||||
from discord import Embed, File, GuildSticker, StickerItem, AllowedMentions, Message, MessageReference, PartialMessage
|
||||
from discord.ui import View
|
||||
|
||||
@@ -770,3 +771,20 @@ def replace_multiple(format_string, mapping):
|
||||
pattern = '|'.join(f"({key})" for key in keys)
|
||||
string = re.sub(pattern, lambda match: str(mapping[keys[match.lastindex - 1]]), format_string)
|
||||
return string
|
||||
|
||||
|
||||
def emojikey(emoji: discord.Emoji | discord.PartialEmoji | str):
|
||||
"""
|
||||
Produces a distinguishing key for an Emoji or PartialEmoji.
|
||||
|
||||
Equality checks using this key should act as expected.
|
||||
"""
|
||||
if isinstance(emoji, _EmojiTag):
|
||||
if emoji.id:
|
||||
key = str(emoji.id)
|
||||
else:
|
||||
key = str(emoji.name)
|
||||
else:
|
||||
key = str(emoji)
|
||||
|
||||
return key
|
||||
|
||||
@@ -47,7 +47,7 @@ class TaskMonitor(Generic[Taskid]):
|
||||
Similar to `schedule_tasks`, but wipe and reset the tasklist.
|
||||
"""
|
||||
self._taskmap = {tid: time for tid, time in tasks}
|
||||
self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * tid * self._taskmap[tid])
|
||||
self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid]))
|
||||
self._wakeup.set()
|
||||
|
||||
def schedule_tasks(self, *tasks: tuple[Taskid, int]) -> None:
|
||||
@@ -59,7 +59,7 @@ class TaskMonitor(Generic[Taskid]):
|
||||
we build an entirely new list, and always wake up the loop.
|
||||
"""
|
||||
self._taskmap |= {tid: time for tid, time in tasks}
|
||||
self._tasklist = sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid])
|
||||
self._tasklist = list(sorted(self._taskmap.keys(), key=lambda tid: -1 * self._taskmap[tid]))
|
||||
self._wakeup.set()
|
||||
|
||||
def schedule_task(self, taskid: Taskid, timestamp: int) -> None:
|
||||
|
||||
@@ -248,6 +248,13 @@ class MessageUI(LeoUI):
|
||||
# Refresh lock, to avoid cache collisions on refresh
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def channel(self):
|
||||
if self._original is not None:
|
||||
return self._original.channel
|
||||
else:
|
||||
return self._message.channel
|
||||
|
||||
# ----- UI API -----
|
||||
async def run(self, interaction: discord.Interaction, **kwargs):
|
||||
"""
|
||||
@@ -366,6 +373,15 @@ class MessageUI(LeoUI):
|
||||
args = await self.make_message()
|
||||
self._message = await channel.send(**args.send_args, view=self)
|
||||
|
||||
async def _redraw(self, args):
|
||||
if self._original and not self._original.is_expired():
|
||||
await self._original.edit_original_response(**args.edit_args, view=self)
|
||||
elif self._message:
|
||||
await self._message.edit(**args.edit_args, view=self)
|
||||
else:
|
||||
# Interaction expired or already closed. Quietly cleanup.
|
||||
await self.close()
|
||||
|
||||
async def redraw(self, thinking: Optional[discord.Interaction] = None):
|
||||
"""
|
||||
Update the output message for this UI.
|
||||
@@ -379,13 +395,7 @@ class MessageUI(LeoUI):
|
||||
asyncio.create_task(thinking.delete_original_response())
|
||||
|
||||
try:
|
||||
if self._original and not self._original.is_expired():
|
||||
await self._original.edit_original_response(**args.edit_args, view=self)
|
||||
elif self._message:
|
||||
await self._message.edit(**args.edit_args, view=self)
|
||||
else:
|
||||
# Interaction expired or already closed. Quietly cleanup.
|
||||
await self.close()
|
||||
await self._redraw(args)
|
||||
except discord.HTTPException:
|
||||
# Unknown communication erorr, nothing we can reliably do. Exit quietly.
|
||||
await self.close()
|
||||
|
||||
@@ -167,7 +167,10 @@ class ModalRetryUI(LeoUI):
|
||||
|
||||
async def respond_to(self, interaction):
|
||||
self._interaction = interaction
|
||||
await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self)
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send(embed=self.embed, ephemeral=True, view=self)
|
||||
else:
|
||||
await interaction.response.send_message(embed=self.embed, ephemeral=True, view=self)
|
||||
|
||||
@button(label="Retry")
|
||||
async def retry_button(self, interaction, butt):
|
||||
|
||||
Reference in New Issue
Block a user