diff --git a/src/modules/ranks/__init__.py b/src/modules/ranks/__init__.py new file mode 100644 index 00000000..8972c73f --- /dev/null +++ b/src/modules/ranks/__init__.py @@ -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)) diff --git a/src/modules/ranks/cog.py b/src/modules/ranks/cog.py new file mode 100644 index 00000000..6c0f6dbc --- /dev/null +++ b/src/modules/ranks/cog.py @@ -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() diff --git a/src/modules/ranks/data.py b/src/modules/ranks/data.py new file mode 100644 index 00000000..900d4194 --- /dev/null +++ b/src/modules/ranks/data.py @@ -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] diff --git a/src/modules/ranks/settings.py b/src/modules/ranks/settings.py new file mode 100644 index 00000000..b2decb5d --- /dev/null +++ b/src/modules/ranks/settings.py @@ -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 diff --git a/src/modules/ranks/ui/__init__.py b/src/modules/ranks/ui/__init__.py new file mode 100644 index 00000000..a57569f7 --- /dev/null +++ b/src/modules/ranks/ui/__init__.py @@ -0,0 +1,4 @@ +from .editor import RankEditor +from .preview import RankPreviewUI +from .overview import RankOverviewUI +from .config import RankConfigUI diff --git a/src/modules/ranks/ui/config.py b/src/modules/ranks/ui/config.py new file mode 100644 index 00000000..c124673f --- /dev/null +++ b/src/modules/ranks/ui/config.py @@ -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 diff --git a/src/modules/ranks/ui/editor.py b/src/modules/ranks/ui/editor.py new file mode 100644 index 00000000..e47874e7 --- /dev/null +++ b/src/modules/ranks/ui/editor.py @@ -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 `#` 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) diff --git a/src/modules/ranks/ui/overview.py b/src/modules/ranks/ui/overview.py new file mode 100644 index 00000000..d6e2ff03 --- /dev/null +++ b/src/modules/ranks/ui/overview.py @@ -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) diff --git a/src/modules/ranks/ui/preview.py b/src/modules/ranks/ui/preview.py new file mode 100644 index 00000000..7d58d3af --- /dev/null +++ b/src/modules/ranks/ui/preview.py @@ -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. + """ + ... diff --git a/src/modules/ranks/utils.py b/src/modules/ranks/utils.py new file mode 100644 index 00000000..24398903 --- /dev/null +++ b/src/modules/ranks/utils.py @@ -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