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,12 @@
import logging
from meta import LionBot
from babel.translator import LocalBabel
babel = LocalBabel('ranks')
logger = logging.getLogger(__name__)
async def setup(bot: LionBot):
from .cog import RankCog
await bot.add_cog(RankCog(bot))

550
src/modules/ranks/cog.py Normal file
View File

@@ -0,0 +1,550 @@
from typing import Optional
import asyncio
import datetime
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.app_commands.transformers import AppCommandOptionType
from cachetools import LRUCache
from meta import LionBot, LionContext, LionCog
from wards import high_management
from core.data import RankType
from utils.ui import ChoicedEnum, Transformed
from utils.lib import utc_now, replace_multiple
from . import babel, logger
from .data import RankData, AnyRankData
from .settings import RankSettings
from .ui import RankOverviewUI, RankConfigUI
from .utils import rank_model_from_type, format_stat_range
_p = babel._p
"""
Update mechanics?
Cache rank list per guild.
Rebuild rank list when ranks are updated through command or UI.
Cache recent member season statistics.
Flush the cached member season statistics when season is updated or reset.
Also cache current member ranks.
Expose interface get_rank(guildid, userid) which hits cache
Expose get_season_time(guildid, userid) which hits cache
Handle voice session ending
Handle xp added
Handle message sessions ending
- We can even do these individually
- As long as we hit cache all the way through the season stat process...
Alternatively, we can add a season_stats database cached table
And let the database handle it.
Of course, every time the season changes, we need to recompute all member statistics.
If we do this with database triggers, we will have to make a single database request each time anyway.
The season_stats table would make leaderboard computation faster though.
And it would make the initial loading for each user a bit faster.
Let's shelve it for now, potential premature optimisation.
We will need local caching for season stats anyway.
On startup, we can compute and memmoize season times for all active members?
Some 2-4k of them per shard.
Current update mechanics are highly not thread safe.
Even with locking, relying on the SeasonRank to stay up to date
but only handle each session event _once_ seems fragile.
Alternatively with a SeasonStats table, could use db as source of truth
and simply trigger a batch-update on event.
"""
class RankTypeChoice(ChoicedEnum):
VOICE = (_p('cmd:configure_ranks|param:rank_type|choice:voice', "Voice"), RankType.VOICE)
XP = (_p('cmd:configure_ranks|param:rank_type|choice:xp', "XP"), RankType.XP)
MESSAGE = (_p('cmd:configure_ranks|param:rank_type|choice:message', "Message"), RankType.MESSAGE)
@property
def choice_name(self):
return self.value[0]
@property
def choice_value(self):
return self.name
class SeasonRank:
"""
Cached season rank information for a given member.
"""
__slots__ = (
'guildid',
'userid',
'current_rank',
'next_rank',
'stat_type',
'stat',
'last_updated',
'rankrow'
)
def __init__(self, guildid, userid, current_rank, next_rank, stat_type, stat, rankrow):
self.guildid: int = guildid
self.userid: int = userid
self.current_rank: AnyRankData = current_rank
self.next_rank: AnyRankData = next_rank
self.stat_type: RankType = stat_type
self.stat: int = stat
self.last_updated = utc_now()
self.rankrow = rankrow
class RankCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(RankData())
self.settings = RankSettings()
# Cached guild ranks for all current guilds. guildid -> list[Rank]
self._guild_ranks = {}
# Cached member SeasonRanks for recently active members
# guildid -> userid -> SeasonRank
# pop the guild whenever the season is updated or the rank type changes.
self._member_ranks = {}
async def cog_load(self):
await self.data.init()
self.bot.core.guild_config.register_model_setting(self.settings.RankStatType)
self.bot.core.guild_config.register_model_setting(self.settings.RankChannel)
self.bot.core.guild_config.register_model_setting(self.settings.DMRanks)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
# ---------- Event handlers ----------
# season_start setting event handler.. clears the guild season rank cache
@LionCog.listener('on_guildset_season_start')
async def handle_season_start(self, guildid, setting):
self._member_ranks.pop(guildid, None)
# guild_leave event handler.. removes the guild from _guild_ranks and clears the season cache
@LionCog.listener('on_guildset_rank_type')
async def handle_rank_type(self, guildid, setting):
self.flush_guild_ranks(guildid)
# rank_type setting event handler.. clears the guild season rank cache and the _guild_ranks cache
# ---------- Cog API ----------
def _get_member_cache(self, guildid: int):
if (cached := self._member_ranks.get(guildid, None)) is None:
guild = self.bot.get_guild(guildid)
if guild and guild.member_count and guild.member_count > 1000:
size = guild.member_count // 10
else:
size = 100
cached = LRUCache(maxsize=size)
self._member_ranks[guildid] = cached
return cached
async def get_member_rank(self, guildid: int, userid: int) -> SeasonRank:
"""
Fetch the SeasonRank info for the given member.
Applies cache where possible.
"""
member_cache = self._get_member_cache(guildid)
if (season_rank := member_cache.get(userid, None)) is None:
# Fetch season rank anew
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
# TODO: Benchmark alltime efficiency
season_start = lguild.config.get('season_start').value or datetime(1970, 1, 1)
stat_data = self.bot.get_cog('StatsCog').data
text_data = self.bot.get_cog('TextTrackerCog').data
member_row = await self.data.MemberRank.fetch_or_create(guildid, userid)
if rank_type is RankType.VOICE:
model = stat_data.VoiceSessionStats
# TODO: Should probably only used saved sessions here...
stat = (await model.study_times_since(guildid, userid, season_start))[0]
if rankid := member_row.current_voice_rankid:
current_rank = await self.data.VoiceRank.fetch(rankid)
else:
current_rank = None
elif rank_type is RankType.XP:
model = stat_data.MemberExp
stat = (await model.xp_since(guildid, userid, season_start))[0]
if rankid := member_row.current_xp_rankid:
current_rank = await self.data.XPRank.fetch(rankid)
else:
current_rank = None
elif rank_type is RankType.MESSAGE:
model = text_data.TextSessions
stat = (await model.member_messages_since(guildid, userid, season_start))[0]
if rankid := member_row.current_msg_rankid:
current_rank = await self.data.MsgRank.fetch(rankid)
else:
current_rank = None
ranks = await self.get_guild_ranks(guildid)
next_rank = None
current = current_rank.required if current_rank is not None else 0
next_rank = next((rank for rank in ranks if rank.required > current), None)
season_rank = SeasonRank(guildid, userid, current_rank, next_rank, rank_type, stat, member_row)
member_cache[userid] = season_rank
return season_rank
async def get_guild_ranks(self, guildid: int, refresh=False) -> list[AnyRankData]:
"""
Get the list of ranks of the correct type in the current guild.
Hits cache where possible, unless `refresh` is set.
"""
# TODO: Fill guild rank caches on cog_load
if refresh or (ranks := self._guild_ranks.get(guildid, None)) is None:
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
rank_model = rank_model_from_type(rank_type)
ranks = await rank_model.fetch_where(guildid=guildid).order_by('required')
self._guild_ranks[guildid] = ranks
return ranks
def flush_guild_ranks(self, guildid: int):
"""
Clear the caches for the given guild.
"""
self._guild_ranks.pop(guildid, None)
self._member_ranks.pop(guildid, None)
async def on_message_session_complete(self, *session_data):
"""
Handle batch of completed message sessions.
"""
tasks = []
# TODO: Thread safety
# TODO: Locking between refresh and individual updates
for guildid, userid, messages, guild_xp in session_data:
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
if rank_type in (RankType.MESSAGE, RankType.XP):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
session_rank.stat += messages if (rank_type is RankType.MESSAGE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
else:
tasks.append(asyncio.create_task(self._role_check(session_rank)))
if tasks:
await asyncio.gather(*tasks)
async def _role_check(self, session_rank: SeasonRank):
guild = self.bot.get_guild(session_rank.guildid)
member = guild.get_member(session_rank.userid)
crank = session_rank.current_rank
roleid = crank.roleid if crank else None
last_roleid = session_rank.rankrow.last_roleid
if guild is not None and member is not None and roleid != last_roleid:
new_role = guild.get_role(roleid) if roleid else None
last_role = guild.get_role(last_roleid) if last_roleid else None
new_last_roleid = last_roleid
if guild.me.guild_permissions.manage_roles:
try:
if last_role and last_role.is_assignable():
await member.remove_roles(last_role)
new_last_roleid = None
if new_role and new_role.is_assignable():
await member.add_roles(new_role)
new_last_roleid = roleid
except discord.HTTPClient:
pass
if new_last_roleid != last_roleid:
await session_rank.rankrow.update(last_roleid=new_last_roleid)
async def update_rank(self, session_rank):
# Identify target rank
guildid = session_rank.guildid
userid = session_rank.userid
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
ranks = await self.get_guild_ranks(guildid)
new_rank = None
for rank in ranks:
if rank.required <= session_rank.stat:
new_rank = rank
else:
break
if new_rank is None or new_rank is session_rank.current_rank:
return
# Attempt to update role
guild = self.bot.get_guild(guildid)
if guild is None:
return
member = guild.get_member(userid)
if member is None:
return
new_role = guild.get_role(new_rank.roleid)
if last_roleid := session_rank.rankrow.last_roleid:
last_role = guild.get_role(last_roleid)
else:
last_role = None
if guild.me.guild_permissions.manage_roles:
try:
if last_role and last_role.is_assignable():
await member.remove_roles(last_role)
last_roleid = None
if new_role and new_role.is_assignable():
await member.add_roles(new_role)
last_roleid = new_role.id
except discord.HTTPException:
pass
# Update MemberRank row
column = {
RankType.MESSAGE: 'current_msg_rankid',
RankType.VOICE: 'current_voice_rankid',
RankType.XP: 'current_xp_rankid'
}[rank_type]
await session_rank.rankrow.update(
**{column: new_rank.rankid, 'last_roleid': last_roleid}
)
# Update SessionRank info
session_rank.current_rank = new_rank
session_rank.next_rank = next((rank for rank in ranks if rank.required > new_rank.required), None)
# Send notification
await self._notify_rank_update(guildid, userid, new_rank)
async def _notify_rank_update(self, guildid, userid, new_rank):
"""
Notify the given member that they have achieved the new rank.
"""
guild = self.bot.get_guild(guildid)
if guild:
member = guild.get_member(userid)
role = guild.get_role(new_rank.roleid)
if member and role:
t = self.bot.translator.t
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
# Build notification embed
rank_mapping = self.get_message_map(rank_type, guild, member, role, new_rank)
rank_message = replace_multiple(new_rank.message, rank_mapping)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'event:rank_update|embed:notify',
"New Activity Rank Attained!"
)),
description=rank_message
)
# Calculate destination
to_dm = lguild.config.get('dm_ranks').value
rank_channel = lguild.config.get('rank_channel').value
if to_dm or not rank_channel:
destination = member
embed.set_author(
name=guild.name,
icon_url=guild.icon.url if guild.icon else None
)
text = None
else:
destination = rank_channel
text = member.mention
# Post!
try:
await destination.send(embed=embed, content=text)
except discord.HTTPException:
# TODO: Logging, guild logging, invalidate channel if permissions are wrong
pass
def get_message_map(self,
rank_type: RankType,
guild: discord.Guild, member: discord.Member,
role: discord.Role, rank: AnyRankData):
t = self.bot.translator.t
required = format_stat_range(rank_type, rank.required, short=False)
key_map = {
'{role_name}': role.name,
'{guild_name}': guild.name,
'{user_name}': member.name,
'{role_id}': role.id,
'{guild_id}': guild.id,
'{user_id}': member.id,
'{role_mention}': role.mention,
'{user_mention}': member.mention,
'{requires}': required,
}
return key_map
async def on_voice_session_complete(self, *session_data):
tasks = []
# TODO: Thread safety
# TODO: Locking between refresh and individual updates
for guildid, userid, duration, guild_xp in session_data:
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
if rank_type in (RankType.VOICE, RankType.XP):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
# TODO: Temporary measure
season_start = lguild.config.get('season_start').value or datetime(1970, 1, 1)
stat_data = self.bot.get_cog('StatsCog').data
session_rank.stat = (await stat_data.study_times_since(guildid, userid, season_start))[0]
# session_rank.stat += duration if (rank_type is RankType.VOICE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
else:
tasks.append(asyncio.create_task(self._role_check(session_rank)))
if tasks:
await asyncio.gather(*tasks)
async def on_xp_update(self, *xp_data):
...
# ---------- Commands ----------
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
@cmds.check(high_management)
async def ranks_cmd(self, ctx: LionContext):
"""
Command to access the Rank Overview UI.
"""
# TODO: Add a command interface to CRUD ranks
# For now just using the clickety interface
# Type checking guards
if not ctx.guild:
return
if not ctx.interaction:
return
ui = RankOverviewUI(self.bot, ctx.guild, ctx.author.id)
await ui.run(ctx.interaction)
await ui.wait()
# ----- Guild Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_ranks', "ranks"),
description=_p('cmd:configure_ranks|desc', "Configure Activity Ranks")
)
@appcmds.rename(
rank_type=RankSettings.RankStatType._display_name,
dm_ranks=RankSettings.DMRanks._display_name,
rank_channel=RankSettings.RankChannel._display_name,
)
@appcmds.describe(
rank_type=RankSettings.RankStatType._desc,
dm_ranks=RankSettings.DMRanks._desc,
rank_channel=RankSettings.RankChannel._desc,
)
@cmds.check(high_management)
async def configure_ranks_cmd(self, ctx: LionContext,
rank_type: Optional[Transformed[RankTypeChoice, AppCommandOptionType.string]] = None,
dm_ranks: Optional[bool] = None,
rank_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None):
# This uses te high management ward
# Because rank modification can potentially delete roles.
t = self.bot.translator.t
# Type checking guards
if not ctx.guild:
return
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
# Retrieve settings from cache
rank_type_setting = await self.settings.RankStatType.get(ctx.guild.id)
dm_ranks_setting = await self.settings.DMRanks.get(ctx.guild.id)
rank_channel_setting = await self.settings.RankChannel.get(ctx.guild.id)
modified = set()
if rank_type is not None:
rank_type_setting.value = rank_type.value[1]
modified.add(rank_type_setting)
if dm_ranks is not None:
dm_ranks_setting.value = dm_ranks
modified.add(dm_ranks_setting)
if rank_channel is not None:
rank_channel_setting.value = rank_channel
modified.add(rank_channel_setting)
# Write and send update ack if required
if modified:
# TODO: Batch
for setting in modified:
await setting.write()
lines = []
if rank_type_setting in modified:
lines.append(rank_type_setting.update_message)
if dm_ranks or rank_channel:
if dm_ranks_setting.value:
if rank_channel_setting.value:
notif_string = t(_p(
'cmd:configure_ranks|response:updated|setting:notification|withdm_withchannel',
"Rank update notifications will be sent via **direct message** when possible, "
"otherwise to {channel}"
)).format(channel=rank_channel_setting.value.mention)
else:
notif_string = t(_p(
'cmd:configure_ranks|response:updated|setting:notification|withdm_nochannel',
"Rank update notifications will be sent via **direct message**."
))
else:
if rank_channel_setting.value:
notif_string = t(_p(
'cmd:configure_ranks|response:updated|setting:notification|nodm_withchannel',
"Rank update notifications will be sent to {channel}."
)).format(channel=rank_channel_setting.value.mention)
else:
notif_string = t(_p(
'cmd:configure_ranks|response:updated|setting:notification|nodm_nochannel',
"Members will not be notified when their activity rank updates."
))
lines.append(notif_string)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description='\n'.join(f"{self.bot.config.emojis.tick} {line}" for line in lines)
)
await ctx.reply(embed=embed)
if ctx.channel.id not in RankConfigUI._listening or not modified:
ui = RankConfigUI(self.bot, ctx.guild.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

109
src/modules/ranks/data.py Normal file
View File

@@ -0,0 +1,109 @@
from typing import TypeAlias, Union
from core.data import RankType
from data import RowModel, Registry, Table, RegisterEnum
from data.columns import Integer, String, Timestamp, Bool
class RankData(Registry):
RankType = RegisterEnum(RankType, name='RankType')
class XPRank(RowModel):
"""
Schema
------
CREATE TABLE xp_ranks(
rankid SERIAL PRIMARY KEY,
roleid BIGINT NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE,
required INTEGER NOT NULL,
reward INTEGER NOT NULL,
message TEXT
);
CREATE UNIQUE INDEX xp_ranks_roleid ON xp_ranks (roleid);
CREATE INDEX xp_ranks_guild_required ON xp_ranks (guildid, required);
"""
_tablename_ = 'xp_ranks'
rankid = Integer(primary=True)
roleid = Integer()
guildid = Integer()
required = Integer()
reward = Integer()
message = String()
class VoiceRank(RowModel):
"""
Schema
------
CREATE TABLE voice_ranks(
rankid SERIAL PRIMARY KEY,
roleid BIGINT NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE,
required INTEGER NOT NULL,
reward INTEGER NOT NULL,
message TEXT
);
CREATE UNIQUE INDEX voice_ranks_roleid ON voice_ranks (roleid);
CREATE INDEX voice_ranks_guild_required ON voice_ranks (guildid, required);
"""
_tablename_ = 'voice_ranks'
rankid = Integer(primary=True)
roleid = Integer()
guildid = Integer()
required = Integer()
reward = Integer()
message = String()
class MsgRank(RowModel):
"""
Schema
------
CREATE TABLE msg_ranks(
rankid SERIAL PRIMARY KEY,
roleid BIGINT NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE,
required INTEGER NOT NULL,
reward INTEGER NOT NULL,
message TEXT
);
CREATE UNIQUE INDEX msg_ranks_roleid ON msg_ranks (roleid);
CREATE INDEX msg_ranks_guild_required ON msg_ranks (guildid, required);
"""
_tablename_ = 'msg_ranks'
rankid = Integer(primary=True)
roleid = Integer()
guildid = Integer()
required = Integer()
reward = Integer()
message = String()
class MemberRank(RowModel):
"""
Schema
------
CREATE TABLE member_ranks(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
current_xp_rankid INTEGER REFERENCES xp_ranks ON DELETE SET NULL,
current_voice_rankid INTEGER REFERENCES voice_ranks ON DELETE SET NULL,
current_msg_rankid INTEGER REFERENCES msg_ranks ON DELETE SET NULL,
last_roleid BIGINT,
PRIMARY KEY (guildid, userid),
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid)
);
"""
_tablename_ = 'member_ranks'
guildid = Integer(primary=True)
userid = Integer(primary=True)
current_xp_rankid = Integer()
current_voice_rankid = Integer()
current_msg_rankid = Integer()
last_roleid = Integer()
AnyRankData: TypeAlias = Union[RankData.XPRank, RankData.VoiceRank, RankData.MsgRank]

View File

@@ -0,0 +1,119 @@
from settings import ModelData
from settings.groups import SettingGroup
from settings.setting_types import BoolSetting, ChannelSetting, EnumSetting
from core.data import RankType, CoreData
from babel.translator import ctx_translator
from . import babel
_p = babel._p
class RankSettings(SettingGroup):
"""
Rank Type
"""
class RankStatType(ModelData, EnumSetting):
"""
The type of statistic used to determine ranks in a Guild.
One of VOICE, XP, or MESSAGE
"""
_enum = RankType
_default = RankType.VOICE
_outputs = {
RankType.VOICE: '`Voice`',
RankType.XP: '`Exp`',
RankType.MESSAGE: '`Messages`'
}
_inputs = {
'voice': RankType.VOICE,
'study': RankType.VOICE,
'text': RankType.MESSAGE,
'message': RankType.MESSAGE,
'messages': RankType.MESSAGE,
'xp': RankType.XP,
'exp': RankType.XP
}
setting_id = 'rank_type'
_event = 'guildset_rank_type'
_display_name = _p('guildset:rank_type', "rank_type")
_desc = _p(
'guildset:rank_type|desc',
"The type of statistic (messages | xp | voice hours) used to determine activity ranks."
)
_long_desc = _p(
'guildset:rank_type|long_desc',
"Which statistic is used to reward activity ranks.\n"
"`Voice` is the number of hours active in tracked voice channels, "
"`Exp` is a measure of message activity, and "
"`Message` is a simple count of messages sent."
)
_model = CoreData.Guild
_column = CoreData.Guild.rank_type.name
@property
def update_message(self):
t = ctx_translator.get().t
if self.value is RankType.VOICE:
resp = t(_p(
'guildset:rank_type|set_response|type:voice',
"Members will be awarded activity ranks based on `Voice Activity`."
))
elif self.value is RankType.MESSAGE:
resp = t(_p(
'guildset:rank_type|set_response|type:messages',
"Members will be awarded activity ranks based on `Messages Sent`."
))
elif self.value is RankType.XP:
resp = t(_p(
'guildset:rank_type|set_response|type:xp',
"Members will be awarded activity ranks based on `Message XP Earned`."
))
return resp
class RankChannel(ModelData, ChannelSetting):
"""
Channel to send Rank notifications.
If DMRanks is set, this will only be used when the target user has disabled DM notifications.
"""
setting_id = 'rank_channel'
_display_name = _p('guildset:rank_channel', "rank_channel")
_desc = _p(
'guildset:rank_channel|desc',
"The channel in which to send rank update notifications."
)
_long_desc = _p(
'guildset:rank_channel|long_desc',
"Whenever a user advances a rank, a congratulatory message will be sent in this channel, if set. "
"If `dm_ranks` is enabled, this channel will only be used when the user has opted not to receive "
"DM notifications, or is otherwise unreachable."
)
_model = CoreData.Guild
_column = CoreData.Guild.rank_channel.name
class DMRanks(ModelData, BoolSetting):
"""
Whether to DM rank notifications.
"""
setting_id = 'dm_ranks'
_display_name = _p('guildset:dm_ranks', "dm_ranks")
_desc = _p(
'guildset:dm_ranks|desc',
"Whether to send rank advancement notifications through direct messages."
)
_long_desc = _p(
'guildset:dm_ranks|long_desc',
"If enabled, congratulatory messages for rank advancement will be direct messaged to the user, "
"instead of being sent to the configured `rank_channel`."
)
_model = CoreData.Guild
_column = CoreData.Guild.dm_ranks.name

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.
"""
...

View File

@@ -0,0 +1,91 @@
from typing import Optional
from core.data import RankType
from utils.lib import strfdur
from babel.translator import ctx_translator
from . import babel
from .data import RankData
_p = babel._p
rank_message_keys = [
("{role_name}", _p('formatstring:rank_message|key:role_name', "{role_name}")),
("{guild_name}", _p('formatstring:rank_message|key:guild_name', "{guild_name}")),
("{user_name}", _p('formatstring:rank_message|key:user_name', "{user_name}")),
("{role_id}", _p('formatstring:rank_message|key:role_id', "{role_id}")),
("{guild_id}", _p('formatstring:rank_message|key:guild_id', "{guild_id}")),
("{user_id}", _p('formatstring:rank_message|key:user_id', "{user_id}")),
("{role_mention}", _p('formatstring:rank_message|key:role_mention', "{role_mention}")),
("{user_mention}", _p('formatstring:rank_message|key:user_mention', "{user_mention}")),
("{requires}", _p('formatstring:rank_message|key:requires', "{rank_requires}")),
]
def rank_model_from_type(rank_type: RankType):
if rank_type is RankType.VOICE:
model = RankData.VoiceRank
elif rank_type is RankType.MESSAGE:
model = RankData.MsgRank
elif rank_type is RankType.XP:
model = RankData.XPRank
return model
def stat_data_to_value(rank_type: RankType, data: int) -> float:
if rank_type is RankType.VOICE:
value = round(data / 36) / 100
else:
value = data
return value
def format_stat_range(rank_type: RankType, start_data: int, end_data: Optional[int] = None, short=True) -> str:
"""
Format the given statistic range into a string, depending on the provided rank type.
"""
# TODO: LOCALISE
if rank_type is RankType.VOICE:
"""
5 - 10 hrs
5 - 10 hours
5h10m - 6h
5h10m - 6 hours
"""
if end_data is not None:
if not start_data % 3600 and not end_data % 3600:
# Both start and end are an even number of hours
# Just divide them by 3600 and stick hrs or hours on the end.
start = start_data // 3600
end = end_data // 3600
suffix = "hrs" if short else "hours"
formatted = f"{start} - {end} {suffix}"
else:
# Not even values, thus strfdur both
start = strfdur(start_data, short=short)
end = strfdur(end_data, short=short)
formatted = f"{start} - {end}"
else:
formatted = strfdur(start_data, short=short)
elif rank_type is RankType.MESSAGE:
suffix = "msgs" if short else "messages"
if end_data is not None:
formatted = f"{start_data} - {end_data} {suffix}"
else:
formatted = f"{start_data} {suffix}"
elif rank_type is RankType.XP:
suffix = "XP"
if end_data is not None:
formatted = f"{start_data} - {end_data} {suffix}"
else:
formatted = f"{start_data} {suffix}"
return formatted
def stat_value_to_data(rank_type: RankType, value: float) -> int:
if rank_type is RankType.VOICE:
data = int(round(value * 100) * 36)
else:
data = value
return data