rewrite: New ranks module.
This commit is contained in:
12
src/modules/ranks/__init__.py
Normal file
12
src/modules/ranks/__init__.py
Normal 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
550
src/modules/ranks/cog.py
Normal 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
109
src/modules/ranks/data.py
Normal 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]
|
||||||
119
src/modules/ranks/settings.py
Normal file
119
src/modules/ranks/settings.py
Normal 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
|
||||||
4
src/modules/ranks/ui/__init__.py
Normal file
4
src/modules/ranks/ui/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .editor import RankEditor
|
||||||
|
from .preview import RankPreviewUI
|
||||||
|
from .overview import RankOverviewUI
|
||||||
|
from .config import RankConfigUI
|
||||||
161
src/modules/ranks/ui/config.py
Normal file
161
src/modules/ranks/ui/config.py
Normal 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
|
||||||
369
src/modules/ranks/ui/editor.py
Normal file
369
src/modules/ranks/ui/editor.py
Normal 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)
|
||||||
378
src/modules/ranks/ui/overview.py
Normal file
378
src/modules/ranks/ui/overview.py
Normal 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)
|
||||||
328
src/modules/ranks/ui/preview.py
Normal file
328
src/modules/ranks/ui/preview.py
Normal 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.
|
||||||
|
"""
|
||||||
|
...
|
||||||
91
src/modules/ranks/utils.py
Normal file
91
src/modules/ranks/utils.py
Normal 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
|
||||||
Reference in New Issue
Block a user