rewrite: New ranks module.

This commit is contained in:
2023-05-14 12:33:33 +03:00
parent 6683d27dfd
commit 90b967201d
10 changed files with 2121 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from .editor import RankEditor
from .preview import RankPreviewUI
from .overview import RankOverviewUI
from .config import RankConfigUI

View File

@@ -0,0 +1,161 @@
import asyncio
import discord
from discord.ui.select import select, ChannelSelect, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from meta import LionBot
from wards import i_high_management
from core.data import RankType
from utils.ui import ConfigUI, DashboardSection
from utils.lib import MessageArgs
from ..settings import RankSettings
from .. import babel, logger
from .overview import RankOverviewUI
_p = babel._p
class RankConfigUI(ConfigUI):
setting_classes = (
RankSettings.RankStatType,
RankSettings.DMRanks,
RankSettings.RankChannel,
)
def __init__(self, bot: LionBot,
guildid: int, channelid: int, **kwargs):
self.settings = bot.get_cog('RankCog').settings
super().__init__(bot, guildid, channelid, **kwargs)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
return await i_high_management(interaction)
# ----- UI Components -----
# Button to summon Overview UI
@button(label="OVERVIEW_PLACEHOLDER", style=ButtonStyle.blurple)
async def overview_button(self, press: discord.Interaction, pressed: Button):
"""
Display the Overview UI
"""
overviewui = RankOverviewUI(self.bot, press.guild, press.user.id)
self._slaves.append(overviewui)
await overviewui.run(press)
async def overview_button_refresh(self):
self.overview_button.label = self.bot.translator.t(_p(
'ui:rank_config|button:overview|label',
"Edit Ranks"
))
# Channel select menu
@select(placeholder="TYPE_SELECT_PLACEHOLDER", min_values=1, max_values=1)
async def type_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True)
setting = self.instances[0]
value = selected.values[0]
data = RankType((value,))
setting.data = data
await setting.write()
await selection.delete_original_response()
async def type_menu_refresh(self):
t = self.bot.translator.t
self.type_menu.placeholder = t(_p(
'ui:rank_config|menu:types|placeholder',
"Select Statistic Type"
))
current = self.instances[0].data
options = [
SelectOption(
label=t(_p(
'ui:rank_config|menu:types|option:voice',
"Voice Activity"
)),
value=RankType.VOICE.value[0],
default=(current is RankType.VOICE)
),
SelectOption(
label=t(_p(
'ui:rank_config|menu:types|option:xp',
"XP Earned"
)),
value=RankType.XP.value[0],
default=(current is RankType.XP)
),
SelectOption(
label=t(_p(
'ui:rank_config|menu:types|option:messages',
"Messages Sent"
)),
value=RankType.MESSAGE.value[0],
default=(current is RankType.MESSAGE)
),
]
self.type_menu.options = options
@select(cls=ChannelSelect, channel_types=[discord.ChannelType.text, discord.ChannelType.news],
placeholder="CHANNEL_SELECT_PLACEHOLDER",
min_values=0, max_values=1)
async def channel_menu(self, selection: discord.Interaction, selected: ChannelSelect):
await selection.response.defer()
setting = self.instances[2]
setting.value = selected.values[0] if selected.values else None
await setting.write()
async def channel_menu_refresh(self):
self.channel_menu.placeholder = self.bot.translator.t(_p(
'ui:rank_config|menu:channels|placeholder',
"Select Rank Notification Channel"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:rank_config|embed|title',
"Ranks Configuration Panel"
))
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
args = MessageArgs(embed=embed)
return args
async def reload(self):
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
self.instances = tuple(
lguild.config.get(setting.setting_id) for setting in self.setting_classes
)
async def refresh_components(self):
await asyncio.gather(
self.overview_button_refresh(),
self.channel_menu_refresh(),
self.type_menu_refresh(),
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
)
self._layout = [
(self.type_menu,),
(self.channel_menu,),
(self.overview_button, self.edit_button, self.reset_button, self.close_button)
]
class RankDashboard(DashboardSection):
section_name = _p(
'dash:rank|title',
"Rank Configuration",
)
configui = RankConfigUI
setting_classes = RankConfigUI.setting_classes

View File

@@ -0,0 +1,369 @@
from typing import Optional
import discord
from discord.ui.text_input import TextInput, TextStyle
from meta import LionBot
from meta.errors import UserInputError
from core.data import RankType
from utils.ui import FastModal, error_handler_for, ModalRetryUI
from utils.lib import parse_duration, replace_multiple
from .. import babel, logger
from ..data import AnyRankData
from ..utils import format_stat_range, rank_model_from_type, rank_message_keys
_p = babel._p
class RankEditor(FastModal):
"""
Create or edit a single Rank.
"""
role_name: TextInput = TextInput(
label='ROLE_NAME_PLACHOLDER',
max_length=128,
required=True
)
def role_name_setup(self):
self.role_name.label = self.bot.translator.t(_p(
'ui:rank_editor|input:role_name|label',
"Role Name"
))
self.role_name.placeholder = self.bot.translator.t(_p(
'ui:rank_editor|input:role_name|placeholder',
"Name of the awarded guild role"
))
def role_name_parse(self) -> str:
return self.role_name.value
role_colour: TextInput = TextInput(
label='ROLE_COLOUR_PLACEHOLDER',
min_length=7,
max_length=16,
required=False
)
def role_colour_setup(self):
self.role_colour.label = self.bot.translator.t(_p(
'ui:rank_editor|input:role_volour|label',
"Role Colour"
))
self.role_colour.placeholder = self.bot.translator.t(_p(
'ui:rank_editor|input:role_colour|placeholder',
"Colour of the awarded guild role, e.g. #AB1321"
))
def role_colour_parse(self) -> discord.Colour:
t = self.bot.translator.t
if self.role_colour.value:
try:
colour = discord.Colour.from_str(self.role_colour.value)
except ValueError:
raise UserInputError(
_msg=t(_p(
'ui:rank_editor|input:role_colour|error:parse',
"`role_colour`: Could not parse colour! Please use `#<hex>` format e.g. `#AB1325`."
))
)
else:
# TODO: Could use a standardised spectrum
# And use the required value to select a colour
colour = discord.Colour.random()
return colour
requires: TextInput = TextInput(
label='REQUIRES_PLACEHOLDER',
max_length=9,
required=True,
)
def requires_setup(self):
if self.rank_type is RankType.VOICE:
self.requires.label = self.bot.translator.t(_p(
'ui:rank_editor|type:voice|input:requires|label',
"Required Voice Hours"
))
self.requires.placholder = self.bot.translator.t(_p(
'ui:rank_editor|type:voice|input:requires|placeholder',
"Number of voice hours before awarding this rank"
))
elif self.rank_type is RankType.XP:
self.requires.label = self.bot.translator.t(_p(
'ui:rank_editor|type:xp|input:requires|label',
"Required XP"
))
self.requires.placholder = self.bot.translator.t(_p(
'ui:rank_editor|type:xp|input:requires|placeholder',
"Amount of XP needed before obtaining this rank"
))
elif self.rank_type is RankType.MESSAGE:
self.requires.label = self.bot.translator.t(_p(
'ui:rank_editor|type:message|input:requires|label',
"Required Message Count"
))
self.requires.placholder = self.bot.translator.t(_p(
'ui:rank_editor|type:message|input:requires|placeholder',
"Number of messages needed before awarding rank"
))
def requires_parse(self) -> int:
t = self.bot.translator.t
value = self.requires.value
# TODO: Bound checking and errors for each type
if self.rank_type is RankType.VOICE:
if value.isdigit():
data = int(value) * 3600
else:
data = parse_duration(self.requires.value)
if not data:
raise UserInputError(
_msg=t(_p(
'ui:rank_editor|type:voice|input:requires|error:parse',
"`requires`: Could not parse provided minimum time! Please write a number of hours."
))
)
elif self.rank_type is RankType.MESSAGE:
value = value.lower().strip(' messages')
if value.isdigit():
data = int(value)
else:
raise UserInputError(
_msg=t(_p(
'ui:rank_editor|type:message|input:requires|error:parse',
"`requires`: Could not parse provided minimum message count! Please enter an integer."
))
)
elif self.rank_type is RankType.XP:
value = value.lower().strip(' xps')
if value.isdigit():
data = int(value)
else:
raise UserInputError(
_msg=t(_p(
'ui:rank_editor|type:xp|input:requires|error:parse',
"`requires`: Could not parse provided minimum XP! Please enter an integer."
))
)
return data
reward: TextInput = TextInput(
label='REWARD_PLACEHOLDER',
max_length=9,
required=False
)
def reward_setup(self):
self.reward.label = self.bot.translator.t(_p(
'ui:rank_editor|input:reward|label',
"LionCoins awarded upon achieving this rank"
))
self.reward.placeholder = self.bot.translator.t(_p(
'ui:rank_editor|input:reward|placeholder',
"LionCoins awarded upon achieving this rank"
))
def reward_parse(self) -> int:
t = self.bot.translator.t
value = self.reward.value
if not value:
# Empty value
data = 0
elif value.isdigit():
data = int(value)
else:
raise UserInputError(
_msg=t(_p(
'ui:rank_editor|input:reward|error:parse',
'`reward`: Please enter an integer number of LionCoins.'
))
)
return data
message: TextInput = TextInput(
label='MESSAGE_PLACEHOLDER',
style=TextStyle.long,
max_length=1024,
required=True
)
def message_setup(self):
t = self.bot.translator.t
self.message.label = t(_p(
'ui:rank_editor|input:message|label',
"Rank Message"
))
self.message.placeholder = t(_p(
'ui:rank_editor|input:message|placeholder',
(
"Congratulatory message sent to the user upon achieving this rank."
)
))
if self.rank_type is RankType.VOICE:
# TRANSLATOR NOTE: Don't change the keys here, they will be automatically replaced by the localised key
msg_default = t(_p(
'ui:rank_editor|input:message|default|type:voice',
(
"Congratulations {user_mention}!\n"
"For working hard for **{requires}**, you have achieved the rank of "
"**{role_name}** in **{guild_name}**! Keep up the good work."
)
))
elif self.rank_type is RankType.XP:
# TRANSLATOR NOTE: Don't change the keys here, they will be automatically replaced by the localised key
msg_default = t(_p(
'ui:rank_editor|input:message|default|type:xp',
(
"Congratulations {user_mention}!\n"
"For earning **{requires}**, you have achieved the guild rank of "
"**{role_name}** in **{guild_name}**!"
)
))
elif self.rank_type is RankType.MESSAGE:
# TRANSLATOR NOTE: Don't change the keys here, they will be automatically replaced by the localised key
msg_default = t(_p(
'ui:rank_editor|input:message|default|type:msg',
(
"Congratulations {user_mention}!\n"
"For sending **{requires}**, you have achieved the guild rank of "
"**{role_name}** in **{guild_name}**!"
)
))
# Replace the progam keys in the default message with the correct localised keys.
replace_map = {pkey: t(lkey) for pkey, lkey in rank_message_keys}
self.message.default = replace_multiple(msg_default, replace_map)
def message_parse(self) -> str:
# Replace the localised keys with programmatic keys
t = self.bot.translator.t
replace_map = {t(lkey): pkey for pkey, lkey in rank_message_keys}
return replace_multiple(self.message.value, replace_map)
def __init__(self, bot: LionBot, rank_type: RankType, **kwargs):
self.bot = bot
self.rank_type = rank_type
self.message_setup()
self.reward_setup()
self.requires_setup()
self.role_name_setup()
self.role_colour_setup()
super().__init__(**kwargs)
@classmethod
async def edit_rank(cls, interaction: discord.Interaction,
rank_type: RankType,
rank: AnyRankData, role: discord.Role,
callback=None):
bot = interaction.client
self = cls(
bot,
rank_type,
title=bot.translator.t(_p('ui:rank_editor|mode:edit|title', "Rank Editor"))
)
self.role_name.default = role.name
self.role_colour.default = str(role.colour)
self.requires.default = format_stat_range(rank_type, rank.required, None, short=True)
self.reward.default = rank.reward
if rank.message:
t = bot.translator.t
replace_map = {pkey: t(lkey) for pkey, lkey in rank_message_keys}
self.message.default = replace_multiple(rank.message, replace_map)
@self.submit_callback(timeout=15*60)
async def _edit_rank_callback(interaction):
# Parse each field in turn
# A parse error will raise UserInputError and trigger ModalRetry
role_name = self.role_name_parse()
role_colour = self.role_colour_parse()
requires = self.requires_parse()
reward = self.reward_parse()
message = self.message_parse()
# Once successful, use rank.update() to edit the rank if modified,
if requires != rank.required or reward != rank.reward or message != rank.message:
# In the corner-case where the rank has been externally deleted, this will be a no-op
await rank.update(
required=requires,
reward=reward,
message=message
)
self.bot.get_cog('RankCog').flush_guild_ranks(interaction.guild.id)
# and edit the role with role.edit() if modified.
if role_name != role.name or role_colour != role.colour:
await role.edit(name=role_name, colour=role_colour)
# Respond with an update ack..
# (Might not be required? Or maybe use ephemeral ack?)
# Finally, run the provided parent callback if provided
if callback is not None:
await callback(rank, interaction)
# Editor ready, now send
await interaction.response.send_modal(self)
return self
@classmethod
async def create_rank(cls, interaction: discord.Interaction,
rank_type: RankType,
guild: discord.Guild, role: Optional[discord.Role] = None,
callback=None):
bot = interaction.client
self = cls(
bot,
rank_type,
title=bot.translator.t(_p(
'ui:rank_editor|mode:create|title',
"Rank Creator"
))
)
if role is not None:
self.role_name.default = role.name
self.role_colour.default = str(role.colour)
@self.submit_callback(timeout=15*60)
async def _create_rank_callback(interaction):
# Parse each field in turn
# A parse error will raise UserInputError and trigger ModalRetry
role_name = self.role_name_parse()
role_colour = self.role_colour_parse()
requires = self.requires_parse()
reward = self.reward_parse()
message = self.message_parse()
# Create or edit the role
if role is not None:
rank_role = role
# Edit role if properties were updated
if (role_name != role.name or role_colour != role.colour):
await role.edit(name=role_name, colour=role_colour)
else:
# Create the role
rank_role = await guild.create_role(name=role_name, colour=role_colour)
# TODO: Move role to correct position, based on rank list
# Create the Rank
model = rank_model_from_type(rank_type)
rank = await model.create(
roleid=rank_role.id,
guildid=guild.id,
required=requires,
reward=reward,
message=message
)
self.bot.get_cog('RankCog').flush_guild_ranks(guild.id)
if callback is not None:
await callback(rank, interaction)
# Editor ready, now send
await interaction.response.send_modal(self)
return self
@error_handler_for(UserInputError)
async def rerequest(self, interaction: discord.Interaction, error: UserInputError):
await ModalRetryUI(self, error.msg).respond_to(interaction)

View File

@@ -0,0 +1,378 @@
from typing import Optional
import asyncio
import discord
from discord.ui.select import select, Select, SelectOption, RoleSelect
from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from core.data import RankType
from data import ORDER
from utils.ui import MessageUI
from utils.lib import MessageArgs
from babel.translator import ctx_translator
from .. import babel, logger
from ..data import AnyRankData
from ..utils import rank_model_from_type, format_stat_range, stat_data_to_value
from .editor import RankEditor
from .preview import RankPreviewUI
_p = babel._p
class RankOverviewUI(MessageUI):
def __init__(self, bot: LionBot, guild: discord.Guild, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.guild = guild
self.guildid = guild.id
self.lguild = None
# List of ranks rows in ASC order
self.ranks: list[AnyRankData] = []
self.rank_type: RankType = None
self.rank_preview: Optional[RankPreviewUI] = None
@property
def rank_model(self):
"""
Return the correct Rank model for the current rank type.
"""
if self.rank_type is None:
return None
else:
return rank_model_from_type(self.rank_type)
# ----- API -----
async def run(self, *args, **kwargs):
await super().run(*args, **kwargs)
# ----- UI Components -----
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
async def quit_button_refresh(self):
pass
@button(label="AUTO_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def auto_button(self, press: discord.Interaction, pressed: Button):
"""
Automatically generate a set of activity ranks for the guild.
Ranks are determined by rank type.
"""
await press.response.send_message("Not Implemented Yet")
async def auto_button_refresh(self):
self.auto_button.label = self.bot.translator.t(_p(
'ui:rank_overview|button:auto|label',
"Auto Create"
))
@button(label="REFRESH_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def refresh_button(self, press: discord.Interaction, pressed: Button):
"""
Refresh the current ranks,
ensuring that all members have the correct rank.
"""
await press.response.send_message("Not Implemented Yet")
async def refresh_button_refresh(self):
self.refresh_button.label = self.bot.translator.t(_p(
'ui:rank_overview|button:refresh|label',
"Refresh Member Ranks"
))
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def clear_button(self, press: discord.Interaction, pressed: Button):
"""
Clear the rank list.
"""
await self.rank_model.table.delete_where(guildid=self.guildid)
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
self.ranks = []
await self.redraw()
async def clear_button_refresh(self):
self.clear_button.label = self.bot.translator.t(_p(
'ui:rank_overview|button:clear|label',
"Clear Ranks"
))
@button(label="CREATE_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
async def create_button(self, press: discord.Interaction, pressed: Button):
"""
Create a new rank, and role to go with it.
Errors if the client does not have permission to create roles.
"""
async def _create_callback(rank, submit: discord.Interaction):
await submit.response.send_message(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description="Rank Created!"
),
ephemeral=True
)
await self.refresh()
await RankEditor.create_rank(
press,
self.rank_type,
self.guild,
callback=_create_callback
)
async def create_button_refresh(self):
self.create_button.label = self.bot.translator.t(_p(
'ui:rank_overview|button:create|label',
"Create Rank"
))
@select(cls=RoleSelect, placeholder="ROLE_SELECT_PLACEHOLDER", min_values=1, max_values=1)
async def role_menu(self, selection: discord.Interaction, selected):
"""
Create a new rank based on the selected role,
or edit an existing rank,
or throw an error if the role is @everyone or not manageable by the client.
"""
role: discord.Role = selected.values[0]
if role.is_assignable():
existing = next((rank for rank in self.ranks if rank.roleid == role.id), None)
if existing:
# Display and edit the given role
await RankEditor.edit_rank(
selection,
self.rank_type,
existing,
role,
callback=self._editor_callback
)
else:
# Create new rank based on role
await RankEditor.create_rank(
selection,
self.rank_type,
self.guild,
role=role,
callback=self._editor_callback
)
else:
# Ack with a complaint depending on the type of error
t = self.bot.translator.t
if role.is_default():
error = t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|suberror:is_default',
"The @everyone role cannot be removed, and cannot be a rank!"
))
elif role.managed:
error = t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|suberror:is_managed',
"The role is managed by another application or integration, and cannot be a rank!"
))
elif not self.guild.me.guild_permissions.manage_roles:
error = t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|suberror:no_permissions',
"I do not have the `MANAGE_ROLES` permission in this server, so I cannot manage ranks!"
))
elif (role >= self.guild.me.top_role):
error = t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|suberror:above_me',
"This role is above my top role in the role hierarchy, so I cannot add or remove it!"
))
else:
# Catch all for other potential issues
error = t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|suberror:other',
"I am not able to manage the selected role, so it cannot be a rank!"
))
embed = discord.Embed(
title=t(_p(
'ui:rank_overview|menu:roles|error:not_assignable|title',
"Could not create rank!"
)),
description=error,
colour=discord.Colour.brand_red()
)
await selection.response.send_message(embed=embed, ephemeral=True)
async def _editor_callback(self, rank: AnyRankData, submit: discord.Interaction):
asyncio.create_task(self.refresh())
await self._open_preview(rank, submit)
async def _open_preview(self, rank: AnyRankData, interaction: discord.Interaction):
previewui = RankPreviewUI(
self.bot, self.guild, self.rank_type, rank, callerid=self._callerid, parent=self
)
if self.rank_preview is not None:
asyncio.create_task(self.rank_preview.quit())
self.rank_preview = previewui
self._slaves = [previewui]
await previewui.run(interaction)
async def role_menu_refresh(self):
self.role_menu.placeholder = self.bot.translator.t(_p(
'ui:rank_overview|menu:roles|placeholder',
"Create from role"
))
@select(cls=Select, placeholder="RANK_PLACEHOLDER", min_values=1, max_values=1)
async def rank_menu(self, selection: discord.Interaction, selected):
"""
Select a rank to open the preview UI for that rank.
Replaces the previously opened preview ui, if open.
"""
rankid = int(selected.values[0])
rank = await self.rank_model.fetch(rankid)
await self._open_preview(rank, selection)
async def rank_menu_refresh(self):
self.rank_menu.placeholder = self.bot.translator.t(_p(
'ui:rank_overview|menu:ranks|placeholder',
"View or edit rank"
))
options = []
for rank in self.ranks:
role = self.guild.get_role(rank.roleid)
name = role.name if role else "Unknown Role"
option = SelectOption(
value=str(rank.rankid),
label=name,
description=format_stat_range(self.rank_type, rank.required, short=False),
)
options.append(option)
self.rank_menu.options = options
# ----- UI Flow -----
def _format_range(self, start: int, end: Optional[int] = None):
"""
Appropriately format the given required amount for the current rank type.
"""
if self.rank_type is RankType.VOICE:
startval = stat_data_to_value(self.rank_type, start)
if end:
endval = stat_data_to_value(self.rank_type, end)
string = f"{startval} - {endval} h"
else:
string = f"{startval} h"
elif self.rank_type is RankType.XP:
if end:
string = f"{start} - {end} XP"
else:
string = f"{start} XP"
elif self.rank_type is RankType.MESSAGE:
if end:
string = f"{start} - {end} msgs"
else:
string = f"{start} msgs"
return string
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
if self.ranks:
# Format the ranks into a neat list
# TODO: Error symbols for non-existent or permitted roles
required = [rank.required for rank in self.ranks]
ranges = list(zip(required, required[1:]))
pad = 1 if len(ranges) < 10 else 2
lines = []
for i, rank in enumerate(self.ranks):
if i == len(self.ranks) - 1:
reqstr = format_stat_range(self.rank_type, rank.required)
rangestr = f"{reqstr}"
else:
start, end = ranges[i]
rangestr = format_stat_range(self.rank_type, start, end)
line = "`[{pos:<{pad}}]` | <@&{roleid}> **({rangestr})**".format(
pad=pad,
pos=i+1,
roleid=rank.roleid,
rangestr=rangestr
)
lines.append(line)
desc = '\n'.join(reversed(lines))
else:
# No ranks, give hints about adding ranks
desc = t(_p(
'ui:rank_overview|embed:noranks|desc',
"No activity ranks have been set up!\n"
"Press 'AUTO' to automatically create a "
"standard heirachy of voice | text | xp ranks, "
"or select a role or press Create below!"
))
if self.rank_type is RankType.VOICE:
title = t(_p(
'ui:rank_overview|embed|title|type:voice',
"Voice Ranks in {guild_name}"
))
elif self.rank_type is RankType.XP:
title = t(_p(
'ui:rank_overview|embed|title|type:xp',
"XP ranks in {guild_name}"
))
elif self.rank_type is RankType.MESSAGE:
title = t(_p(
'ui:rank_overview|embed|title|type:message',
"Message ranks in {guild_name}"
))
title = title.format(guild_name=self.guild.name)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title,
description=desc
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
if self.ranks:
# If the guild has at least one rank setup
await asyncio.gather(
self.rank_menu_refresh(),
self.role_menu_refresh(),
self.refresh_button_refresh(),
self.create_button_refresh(),
self.clear_button_refresh(),
self.quit_button_refresh(),
)
self.set_layout(
(self.rank_menu,),
(self.role_menu,),
(self.refresh_button, self.create_button, self.clear_button, self.quit_button)
)
else:
# If the guild has no ranks set up
await asyncio.gather(
self.role_menu_refresh(),
self.auto_button_refresh(),
self.create_button_refresh(),
self.quit_button_refresh(),
)
self.set_layout(
(self.role_menu,),
(self.auto_button, self.create_button, self.quit_button)
)
async def reload(self):
"""
Refresh the rank list and type from data.
"""
self.lguild = await self.bot.core.lions.fetch_guild(self.guildid)
self.rank_type = self.lguild.config.get('rank_type').value
self.ranks = await self.rank_model.fetch_where(
guildid=self.guildid
).order_by('required', ORDER.ASC)

View File

@@ -0,0 +1,328 @@
from typing import Optional
import asyncio
import discord
from discord.ui.select import select, RoleSelect
from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from core.data import RankType
from utils.ui import MessageUI, AButton, AsComponents
from utils.lib import MessageArgs, replace_multiple
from babel.translator import ctx_translator
from .. import babel, logger
from ..data import AnyRankData
from ..utils import format_stat_range, rank_message_keys
from .editor import RankEditor
_p = babel._p
class RankPreviewUI(MessageUI):
"""
Preview and edit a single guild rank.
This UI primarily serves as a platform for deleting the rank and changing the underlying role.
"""
def __init__(self, bot: LionBot,
guild: discord.Guild,
rank_type: RankType, rank: AnyRankData,
parent: Optional[MessageUI] = None,
**kwargs):
super().__init__(**kwargs)
self.bot = bot
self.guild = guild
self.guildid = guild.id
self.rank_type = rank_type
self.rank = rank
self.parent = parent
# ----- UI API -----
# ----- UI Components -----
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
async def quit_button_refresh(self):
pass
@button(label="EDIT_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_button(self, press: discord.Interaction, pressed: Button):
"""
Open the rank editor for the underlying rank.
Silent callback, just reload the UI.
"""
t = self.bot.translator.t
role = self.guild.get_role(self.rank.roleid)
error = None
if role is None:
# Role no longer exists, prompt to select a new role
error = t(_p(
'ui:rank_preview|button:edit|error:role_deleted',
"The role underlying this rank no longer exists! "
"Please select a new role from the role menu."
))
elif not role.is_assignable():
# Role exists but is invalid, prompt to select a new role
error = t(_p(
'ui:rank_preview|button:edit|error:role_not_assignable',
"I do not have permission to edit the underlying role! "
"Please select a new role from the role menu, "
"or ensure my top role is above the selected role."
))
if error is not None:
embed = discord.Embed(
title=t(_p(
'ui:rank_preview|button:edit|error|title',
"Failed to edit rank!"
)),
description=error,
colour=discord.Colour.brand_red()
)
await press.response.send_message(embed=embed)
else:
await RankEditor.edit_rank(
press,
self.rank_type,
self.rank,
role,
callback=self._editor_callback
)
async def edit_button_refresh(self):
self.edit_button.label = self.bot.translator.t(_p(
'ui:rank_preview|button:edit|label',
"Edit"
))
async def _editor_callback(self, rank: AnyRankData, submit: discord.Interaction):
await submit.response.defer(thinking=False)
if self.parent is not None:
asyncio.create_task(self.parent.refresh())
await self.refresh()
@button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red)
async def delete_button(self, press: discord.Interaction, pressed: Button):
"""
Delete the current rank, post a deletion message, and quit the UI.
Also refreshes the parent, if set.
"""
t = self.bot.translator.t
await press.response.defer(thinking=True, ephemeral=True)
roleid = self.rank.roleid
role = self.guild.get_role(roleid)
if not (role and self.guild.me.guild_permissions.manage_roles and self.guild.me.top_role > role):
role = None
await self.rank.delete()
mention = role.mention if role else str(self.rank.roleid)
if role:
desc = t(_p(
'ui:rank_preview|button:delete|response:success|description|with_role',
"You have deleted the rank {mention}. Press the button below to also delete the role."
)).format(mention=mention)
else:
desc = t(_p(
'ui:rank_preview|button:delete|response:success|description|no_role',
"You have deleted the rank {mention}."
)).format(mention=mention)
embed = discord.Embed(
title=t(_p(
'ui:rank_preview|button:delete|response:success|title',
"Rank Deleted"
)),
description=desc,
colour=discord.Colour.red()
)
if role:
# Add a micro UI to the response to delete the underlying role
delete_role_label = t(_p(
'ui:rank_preview|button:delete|response:success|button:delete_role|label',
"Delete Role"
))
@AButton(label=delete_role_label, style=ButtonStyle.red)
async def delete_role(_press: discord.Interaction, pressed: Button):
# Don't need an interaction check here because the message is ephemeral
rolename = role.name
try:
await role.delete()
errored = False
except discord.HTTPException:
errored = True
if errored:
embed.description = t(_p(
'ui:rank_preview|button:delete|response:success|button:delete_role|response:errored|desc',
"You have deleted the rank **{name}**! "
"Could not delete the role due to an unknown error."
)).format(name=rolename)
else:
embed.description = t(_p(
'ui:rank_preview|button:delete|response:success|button:delete_role|response:success|desc',
"You have deleted the rank **{name}** along with the underlying role."
)).format(name=rolename)
await press.edit_original_response(embed=embed, view=None)
await press.edit_original_response(embed=embed, view=AsComponents(delete_role))
else:
# Just send the deletion embed
await press.edit_original_response(embed=embed)
if self.parent is not None and not self.parent.is_finished():
asyncio.create_task(self.parent.refresh())
await self.quit()
async def delete_button_refresh(self):
self.delete_button.label = self.bot.translator.t(_p(
'ui:rank_preview|button:delete|label',
"Delete Rank"
))
@select(cls=RoleSelect, placeholder="NEW_ROLE_MENU", min_values=1, max_values=1)
async def role_menu(self, selection: discord.Interaction, selected):
"""
Select a new role for this rank.
Certain checks are enforced.
Note this can potentially create two ranks with the same role.
This will not cause any systemic issues aside from confusion.
"""
t = self.bot.translator.t
role: discord.Role = selected.values[0]
await selection.response.defer(thinking=True, ephemeral=True)
if role.is_assignable():
# Update the rank role
await self.rank.update(roleid=role.id)
if self.parent is not None and not self.parent.is_finished():
asyncio.create_task(self.parent.refresh())
await self.refresh(thinking=selection)
else:
if role.is_default():
error = t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|suberror:is_default',
"The @everyone role cannot be removed, and cannot be a rank!"
))
elif role.managed:
error = t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|suberror:is_managed',
"The role is managed by another application or integration, and cannot be a rank!"
))
elif not self.guild.me.guild_permissions.manage_roles:
error = t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|suberror:no_permissions',
"I do not have the `MANAGE_ROLES` permission in this server, so I cannot manage ranks!"
))
elif (role >= self.guild.me.top_role):
error = t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|suberror:above_me',
"This role is above my top role in the role hierarchy, so I cannot add or remove it!"
))
else:
# Catch all for other potential issues
error = t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|suberror:other',
"I am not able to manage the selected role, so it cannot be a rank!"
))
embed = discord.Embed(
title=t(_p(
'ui:rank_preview|menu:roles|error:not_assignable|title',
"Could not update rank!"
)),
description=error,
colour=discord.Colour.brand_red()
)
await selection.edit_original_response(embed=embed)
async def role_menu_refresh(self):
self.role_menu.placeholder = self.bot.translator.t(_p(
'ui:rank_preview|menu:roles|placeholder',
"Update Rank Role"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
# TODO: Localise
t = self.bot.translator.t
rank = self.rank
embed = discord.Embed(
title=t(_p(
'ui:rank_preview|embed|title',
"Rank Information"
)),
colour=discord.Colour.orange()
)
embed.add_field(
name=t(_p(
'ui:rank_preview|embed|field:role|name',
"Role"
)),
value=f"<@&{rank.roleid}>"
)
embed.add_field(
name=t(_p(
'ui:rank_preview|embed|field:required|name',
"Required"
)),
value=format_stat_range(self.rank_type, rank.required, short=False)
)
embed.add_field(
name=t(_p(
'ui:rank_preview|embed|field:reward|name',
"Reward"
)),
value=f"{conf.emojis.coin}**{rank.reward}**"
)
replace_map = {pkey: t(lkey) for pkey, lkey in rank_message_keys}
message = replace_multiple(rank.message, replace_map)
embed.add_field(
name=t(_p(
'ui:rank_preview|embed|field:message',
"Congratulatory Message"
)),
value=f"```{message}```"
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
await asyncio.gather(
self.role_menu_refresh(),
self.edit_button_refresh(),
self.delete_button_refresh(),
self.quit_button_refresh()
)
self.set_layout(
(self.role_menu,),
(self.edit_button, self.delete_button, self.quit_button,)
)
async def reload(self):
"""
Refresh the stored rank data.
Generally not required since RankData uses a Registry pattern.
"""
...