From 2eece69760289abc2fbfbe9cc5b67c6905e1c6fc Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 13 Aug 2023 08:10:39 +0300 Subject: [PATCH] rewrite: New 'Member Admin' module. --- src/core/lion_member.py | 6 +- src/core/setting_types.py | 12 +- src/modules/__init__.py | 1 + src/modules/config/dashboard.py | 10 +- src/modules/member_admin/__init__.py | 10 + src/modules/member_admin/cog.py | 382 +++++++++++++++++++++++ src/modules/member_admin/data.py | 7 + src/modules/member_admin/settings.py | 419 ++++++++++++++++++++++++++ src/modules/member_admin/settingui.py | 258 ++++++++++++++++ src/modules/schedule/cog.py | 2 +- src/modules/schedule/ui/settingui.py | 38 +++ src/modules/statistics/settings.py | 11 +- src/settings/ui.py | 2 +- src/utils/lib.py | 16 + src/utils/ui/config.py | 11 +- src/utils/ui/msgeditor.py | 14 +- 16 files changed, 1183 insertions(+), 16 deletions(-) create mode 100644 src/modules/member_admin/__init__.py create mode 100644 src/modules/member_admin/cog.py create mode 100644 src/modules/member_admin/data.py create mode 100644 src/modules/member_admin/settings.py create mode 100644 src/modules/member_admin/settingui.py diff --git a/src/core/lion_member.py b/src/core/lion_member.py index 1fbb8cc6..1f3c91b1 100644 --- a/src/core/lion_member.py +++ b/src/core/lion_member.py @@ -108,5 +108,7 @@ class LionMember(Timezoned): # TODO: Logging, audit logging pass else: - # TODO: Persistent role removal - ... + # Remove the role from persistent role storage + cog = self.bot.get_cog('MemberAdminCog') + if cog: + await cog.absent_remove_role(self.guildid, self.userid, role.id) diff --git a/src/core/setting_types.py b/src/core/setting_types.py index bba1d185..fdfb26ac 100644 --- a/src/core/setting_types.py +++ b/src/core/setting_types.py @@ -6,6 +6,7 @@ import json import traceback import discord +from discord.enums import TextStyle from settings.base import ParentID from settings.setting_types import IntegerSetting, StringSetting @@ -13,6 +14,7 @@ from meta import conf from meta.errors import UserInputError from constants import MAX_COINS from babel.translator import ctx_translator +from utils.lib import MessageArgs from . import babel @@ -120,7 +122,7 @@ class MessageSetting(StringSetting): return decoded @classmethod - def value_to_args(cls, parent_id: ParentID, value: dict, **kwargs): + def value_to_args(cls, parent_id: ParentID, value: dict, **kwargs) -> MessageArgs: if not value: return None @@ -133,6 +135,8 @@ class MessageSetting(StringSetting): embeds = [] for embed_data in value['embeds']: embeds.append(discord.Embed.from_dict(embed_data)) + args['embeds'] = embeds + return MessageArgs(**args) @classmethod def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs): @@ -268,3 +272,9 @@ class MessageSetting(StringSetting): formatted = content return formatted + + @property + def input_field(self): + field = super().input_field + field.style = TextStyle.long + return field diff --git a/src/modules/__init__.py b/src/modules/__init__.py index cb475d5e..c71bd2af 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -14,6 +14,7 @@ active = [ '.pomodoro', '.rooms', '.rolemenus', + '.member_admin', '.meta', '.test', ] diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index e6cb70cc..767a840f 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -17,7 +17,9 @@ from modules.pomodoro.settingui import TimerDashboard from modules.rooms.settingui import RoomDashboard from babel.settingui import LocaleDashboard from modules.schedule.ui.settingui import ScheduleDashboard -# from modules.statistics.settings import StatisticsConfigUI +from modules.statistics.settings import StatisticsDashboard +from modules.member_admin.settingui import MemberAdminDashboard + from . import babel, logger @@ -30,9 +32,9 @@ class GuildDashboard(BasePager): Paged UI providing an overview of the guild configuration. """ pages = [ - (LocaleDashboard, EconomyDashboard, TasklistDashboard), - (VoiceTrackerDashboard, TextTrackerDashboard, ), - (RankDashboard, TimerDashboard, RoomDashboard, ), + (MemberAdminDashboard, LocaleDashboard, EconomyDashboard,), + (VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,), + (TasklistDashboard, RoomDashboard, TimerDashboard,), (ScheduleDashboard,), ] diff --git a/src/modules/member_admin/__init__.py b/src/modules/member_admin/__init__.py new file mode 100644 index 00000000..a3566b15 --- /dev/null +++ b/src/modules/member_admin/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +logger = logging.getLogger(__name__) +babel = LocalBabel('member_admin') + + +async def setup(bot): + from .cog import MemberAdminCog + await bot.add_cog(MemberAdminCog(bot)) diff --git a/src/modules/member_admin/cog.py b/src/modules/member_admin/cog.py new file mode 100644 index 00000000..e91621ed --- /dev/null +++ b/src/modules/member_admin/cog.py @@ -0,0 +1,382 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionCog, LionBot, LionContext +from meta.logger import log_wrap +from meta.sharding import THIS_SHARD +from utils.lib import utc_now + +from wards import low_management_ward, equippable_role, high_management_ward + +from . import babel, logger +from .data import MemberAdminData +from .settings import MemberAdminSettings +from .settingui import MemberAdminUI + +_p = babel._p + + +class MemberAdminCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + + self.data = bot.db.load_registry(MemberAdminData()) + self.settings = MemberAdminSettings() + + # Set of (guildid, userid) that are currently being added + self._adding_roles = set() + + # ----- Initialisation ----- + async def cog_load(self): + await self.data.init() + + for setting in self.settings.guild_model_settings: + self.bot.core.guild_config.register_model_setting(setting) + + # Load the config command + configcog = self.bot.get_cog('ConfigCog') + if configcog is None: + logger.warning( + "Loading MemberAdminCog before ConfigCog. " + "Configuration command cannot be crossloaded." + ) + else: + self.crossload_group(self.configure_group, configcog.configure_group) + + # ----- Cog API ----- + async def absent_remove_role(self, guildid, userid, roleid): + """ + Idempotently remove a role from a member who is no longer in the guild. + """ + return await self.data.past_roles.delete_where(guildid=guildid, userid=userid, roleid=roleid) + + # ----- Event Handlers ----- + @LionCog.listener('on_member_join') + @log_wrap(action="Greetings") + async def admin_greet_member(self, member: discord.Member): + lion = await self.bot.core.lions.fetch_member(member.guild.id, member.id, member=member) + + if lion.data.first_joined and lion.data.first_joined > member.joined_at: + # Freshly created member + # At least we haven't seen them join before + + # Give them the welcome message + welcome = lion.lguild.config.get(self.settings.GreetingMessage.setting_id) + if welcome.data and not member.bot: + channel = lion.lguild.config.get(self.settings.GreetingChannel.setting_id).value + if not channel: + # If channel is unset, use direct message + channel = member + + formatter = await welcome.generate_formatter(self.bot, member) + formatted = await formatter(welcome.value) + args = welcome.value_to_args(member.guild.id, formatted) + + try: + await channel.send(**args.send_args) + except discord.HTTPException as e: + logger.info( + f"Welcome message failed for in : " + f"{e.text}" + ) + else: + logger.debug( + f"Welcome message sent to in ." + ) + + # Give them their autoroles + setting = self.settings.BotAutoroles if member.bot else self.settings.Autoroles + autoroles = await setting.get(member.guild.id) + # Filter non-existent roles + roles = [member.guild.get_role(role.id) for role in autoroles.value] + roles = [role for role in roles if role] + roles = [role for role in roles if role and role.is_assignable()] + if roles: + try: + self._adding_roles.add((member.guild.id, member.id)) + await member.add_roles(*roles, reason="Adding Configured Autoroles") + except discord.HTTPException as e: + logger.info( + f"Autoroles failed for in : {e.text}" + ) + else: + logger.debug( + f"Gave autoroles to in " + ) + finally: + self._adding_roles.discard((member.guild.id, member.id)) + else: + # Returning member + + # Give them the returning message + returning = lion.lguild.config.get(self.settings.ReturningMessage.setting_id) + if not returning.data: + returning = lion.lguild.config.get(self.settings.GreetingMessage.setting_id) + if returning.data and not member.bot: + channel = lion.lguild.config.get(self.settings.GreetingChannel.setting_id).value + if not channel: + # If channel is unset, use direct message + channel = member + + last_seen = lion.data.last_left or lion.data._timestamp + formatter = await returning.generate_formatter( + self.bot, member, last_seen=last_seen.timestamp() + ) + formatted = await formatter(returning.value) + args = returning.value_to_args(member.guild.id, formatted) + + try: + await channel.send(**args.send_args) + except discord.HTTPException as e: + logger.info( + f"Returning message failed for in : " + f"{e.text}" + ) + else: + logger.debug( + f"Returning message sent to in ." + ) + + # Give them their old roles if we have them, else autoroles + persistence = lion.lguild.config.get(self.settings.RolePersistence.setting_id).value + if persistence and not member.bot: + rows = await self.data.past_roles.select_where(guildid=member.guild.id, userid=member.id) + roles = [member.guild.get_role(row['roleid']) for row in rows] + roles = [role for role in roles if role and role.is_assignable()] + if roles: + try: + self._adding_roles.add((member.guild.id, member.id)) + await member.add_roles(*roles, reason="Restoring Member Roles") + except discord.HTTPException as e: + logger.info( + f"Role restore failed for in : {e.text}" + ) + else: + logger.debug( + f"Restored roles to in " + ) + finally: + self._adding_roles.discard((member.guild.id, member.id)) + else: + setting = self.settings.BotAutoroles if member.bot else self.settings.Autoroles + autoroles = await setting.get(member.guild.id) + roles = [member.guild.get_role(role.id) for role in autoroles.value] + roles = [role for role in roles if role and role.is_assignable()] + if roles: + try: + self._adding_roles.add((member.guild.id, member.id)) + await member.add_roles(*roles, reason="Adding Configured Autoroles") + except discord.HTTPException as e: + logger.info( + f"Autoroles failed for in : {e.text}" + ) + else: + logger.debug( + f"Gave autoroles to in " + ) + finally: + self._adding_roles.discard((member.guild.id, member.id)) + + @LionCog.listener('on_member_remove') + @log_wrap(action="Farewell") + async def admin_member_farewell(self, member: discord.Member): + # Ignore members that just joined + if (member.guild.id, member.id) in self._adding_roles: + return + + # Set lion last_left, creating the lion_member if needed + lion = await self.bot.core.lions.fetch_member(member.guild.id, member.id) + await lion.data.update(last_left=utc_now()) + + # Save member roles + conn = await self.bot.db.get_connection() + async with conn.transaction(): + await self.data.past_roles.delete_where( + guildid=member.guild.id, + userid=member.id + ) + # Insert current member roles + if member.roles: + await self.data.past_roles.insert_many( + ('guildid', 'userid', 'roleid'), + *((member.guild.id, member.id, role.id) for role in member.roles) + ) + logger.debug( + f"Stored persisting roles for member in ." + ) + + @LionCog.listener('on_guild_join') + async def admin_init_guild(self, guild: discord.Guild): + ... + + @LionCog.listener('on_guild_leave') + @log_wrap(action='Destroy Guild') + async def admin_destroy_guild(self, guild: discord.Guild): + # Clear persisted roles for this guild + await self.data.past_roles.delete_where(guildid=guild.id) + logger.info(f"Cleared persisting roles for guild because we left the guild.") + + @LionCog.listener('on_guildset_role_persistence') + async def clear_stored_roles(self, guildid, data): + if data is False: + await self.data.past_roles.delete_where(guildid=guildid) + logger.info( + f"Cleared persisting roles for guild because they disabled persistence." + ) + + # ----- Cog Commands ----- + @cmds.hybrid_command( + name=_p('cmd:resetmember', "resetmember"), + description=_p( + 'cmd:resetmember|desc', + "Reset (server-associated) member data for the target member or user." + ) + ) + @appcmds.rename( + target=_p('cmd:resetmember|param:target', "member_to_reset"), + saved_roles=_p('cmd:resetmember|param:saved_roles', "saved_roles"), + ) + @appcmds.describe( + target=_p( + 'cmd:resetmember|param:target|desc', + "Choose the member (current or past) you want to reset." + ), + saved_roles=_p( + 'cmd:resetmember|param:saved_roles|desc', + "Clear the saved roles for this member, so their past roles are not restored on rejoin." + ), + ) + @high_management_ward + async def cmd_resetmember(self, ctx: LionContext, + target: discord.User, + saved_roles: Optional[bool] = False, + # voice_activity: Optional[bool] = False, + # text_activity: Optional[bool] = False, + # coins: Optional[bool] = False, + # everything: Optional[bool] = False, + ): + if not ctx.guild: + return + if not ctx.interaction: + return + t = self.bot.translator.t + + if saved_roles: + await self.data.past_roles.delete_where( + guildid=ctx.guild.id, + userid=target.id, + ) + await ctx.reply( + t(_p( + 'cmd:resetmember|reset:saved_roles|success', + "The saved roles for {target} have been reset. " + "They will not regain their roles if they rejoin." + )).format(target=target.mention) + ) + else: + await ctx.error_reply( + t(_p( + 'cmd:resetmember|error:nothing_to_do', + "No reset operation selected, nothing to do." + )), + ephemeral=True + ) + + + # ----- Config Commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group('configure', with_app_command=False) + async def configure_group(self, ctx: LionContext): + """ + Substitute configure command group. + """ + pass + + @configure_group.command( + name=_p('cmd:configure_welcome', "welcome"), + description=_p( + 'cmd:configure_welcome|desc', + "Configure new member greetings and roles." + ) + ) + @appcmds.rename( + greeting_channel=MemberAdminSettings.GreetingChannel._display_name, + role_persistence=MemberAdminSettings.RolePersistence._display_name, + welcome_message=MemberAdminSettings.GreetingMessage._display_name, + returning_message=MemberAdminSettings.ReturningMessage._display_name, + ) + @appcmds.describe( + greeting_channel=MemberAdminSettings.GreetingChannel._desc, + role_persistence=MemberAdminSettings.RolePersistence._desc, + welcome_message=MemberAdminSettings.GreetingMessage._desc, + returning_message=MemberAdminSettings.ReturningMessage._desc, + ) + @low_management_ward + async def configure_welcome(self, ctx: LionContext, + greeting_channel: Optional[discord.TextChannel|discord.VoiceChannel] = None, + role_persistence: Optional[bool] = None, + welcome_message: Optional[discord.Attachment] = None, + returning_message: Optional[discord.Attachment] = None, + ): + # Type checking guards + if not ctx.guild: + return + if not ctx.interaction: + return + + await ctx.interaction.response.defer(thinking=True) + + modified = [] + + if greeting_channel is not None: + setting = self.settings.GreetingChannel + await setting._check_value(ctx.guild.id, greeting_channel) + instance = setting(ctx.guild.id, None) + instance.value = greeting_channel + modified.append(instance) + + if role_persistence is not None: + setting = self.settings.RolePersistence + instance = setting(ctx.guild.id, role_persistence) + modified.append(instance) + + if welcome_message is not None: + setting = self.settings.GreetingMessage + content = await setting.download_attachment(welcome_message) + instance = await setting.from_string(ctx.guild.id, content) + modified.append(instance) + + if returning_message is not None: + setting = self.settings.ReturningMessage + content = await setting.download_attachment(returning_message) + instance = await setting.from_string(ctx.guild.id, content) + modified.append(instance) + + if modified: + ack_lines = [] + update_args = {} + for instance in modified: + ack_lines.append(instance.update_message) + update_args[instance._column] = instance._data + + # Data update + await ctx.lguild.data.update(**update_args) + for instance in modified: + instance.dispatch_update() + + # Ack modified + tick = self.bot.config.emojis.tick + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description='\n'.join(f"{tick} {line}" for line in ack_lines), + ) + await ctx.reply(embed=embed) + + if ctx.channel.id not in MemberAdminUI._listening or not modified: + ui = MemberAdminUI(self.bot, ctx.guild.id, ctx.channel.id) + await ui.run(ctx.interaction) + await ui.wait() diff --git a/src/modules/member_admin/data.py b/src/modules/member_admin/data.py new file mode 100644 index 00000000..966730dd --- /dev/null +++ b/src/modules/member_admin/data.py @@ -0,0 +1,7 @@ +from data import Registry, Table + + +class MemberAdminData(Registry): + autoroles = Table('autoroles') + bot_autoroles = Table('bot_autoroles') + past_roles = Table('past_member_roles') diff --git a/src/modules/member_admin/settings.py b/src/modules/member_admin/settings.py new file mode 100644 index 00000000..ef75d9b2 --- /dev/null +++ b/src/modules/member_admin/settings.py @@ -0,0 +1,419 @@ +from typing import Any, Optional + +import discord +from babel.translator import ctx_translator +from core.data import CoreData +from core.setting_types import MessageSetting +from meta import LionBot +from settings import ListData, ModelData +from settings.groups import SettingGroup +from settings.setting_types import BoolSetting, ChannelSetting, RoleListSetting +from utils.lib import recurse_map, replace_multiple, tabulate + +from . import babel +from .data import MemberAdminData + +_p = babel._p + +_greeting_subkey_desc = { + '{mention}': _p('guildset:greeting_message|formatkey:mention', + "Mention the new member."), + '{user_name}': _p('guildset:greeting_message|formatkey:user_name', + "Display name of the new member."), + '{user_avatar}': _p('guildset:greeting_message|formatkey:user_avatar', + "Avatar url of the new member."), + '{guild_name}': _p('guildset:greeting_message|formatkey:guild_name', + "Name of this server."), + '{guild_icon}': _p('guildset:greeting_message|formatkey:guild_icon', + "Server icon url."), + '{studying_count}': _p('guildset:greeting_message|formatkey:studying_count', + "Number of current voice channel members."), + '{member_count}': _p('guildset:greeting_message|formatkey:member_count', + "Number of members in the server."), +} + + +class MemberAdminSettings(SettingGroup): + class GreetingChannel(ModelData, ChannelSetting): + setting_id = 'greeting_channel' + + _display_name = _p('guildset:greeting_channel', "welcome_channel") + _desc = _p( + 'guildset:greeting_channel|desc', + "Channel in which to welcome new members to the server." + ) + _long_desc = _p( + 'guildset:greeting_channel|long_desc', + "New members will be sent the configured `welcome_message` in this channel, " + "and returning members will be sent the configured `returning_message`. " + "Unset to send these message via direct message." + ) + _accepts = _p( + 'guildset:greeting_channel|accepts', + "Name or id of the greeting channel, or 0 for DM." + ) + + _model = CoreData.Guild + _column = CoreData.Guild.greeting_channel.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is None: + # Greetings will be sent via DM + resp = t(_p( + 'guildset:greeting_channel|set_response:unset', + "Welcome messages will now be sent via direct message." + )) + else: + resp = t(_p( + 'guildset:greeting_channel|set_response:set', + "Welcome messages will now be sent to {channel}" + )).format(channel=value.mention) + return resp + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + t = ctx_translator.get().t + if data is not None: + return f"<#{data}>" + else: + return t(_p( + 'guildset:greeting_channel|formmatted:unset', + "Direct Message" + )) + + class GreetingMessage(ModelData, MessageSetting): + setting_id = 'greeting_message' + + _display_name = _p( + 'guildset:greeting_message', "welcome_message" + ) + _desc = _p( + 'guildset:greeting_message|desc', + "Custom message used to greet new members when they join the server." + ) + _long_desc = _p( + 'guildset:greeting_message|long_desc', + "When set, this message will be sent to the `welcome_channel` when a *new* member joins the server. " + "If not set, no message will be sent." + ) + _accepts = _p( + 'guildset:greeting_message|accepts', + "JSON formatted greeting message data" + ) + _soft_default = _p( + 'guildset:greeting_message|default', + r""" + { + "embed": { + "title": "Welcome {user_name}!", + "thumbnail": {"url": "{user_avatar}"}, + "description": "Welcome to **{guild_name}**!", + "footer": { + "text": "You are the {member_count}th member!" + }, + "color": 15695665 + }, + } + """ + ) + + _model = CoreData.Guild + _column = CoreData.Guild.greeting_message.name + + _subkey_desc = _greeting_subkey_desc + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is None: + # No greetings + resp = t(_p( + 'guildset:greeting_message|set_response:unset', + "Welcome message unset! New members will not be greeted." + )) + else: + resp = t(_p( + 'guildset:greeting_message|set_response:set', + "The welcome message has been updated." + )) + return resp + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + t = ctx_translator.get().t + if data is not None: + return super()._format_data(parent_id, data, **kwargs) + else: + return t(_p( + 'guildset:greeting_message|formmatted:unset', + "Not set, members will not be welcomed." + )) + + @classmethod + async def generate_formatter(cls, bot: LionBot, member: discord.Member, **kwargs): + """ + Generate a formatter function for this message from the given context. + + The formatter function both accepts and returns a message data dict. + """ + async def formatter(data_dict: Optional[dict[str, Any]]): + if not data_dict: + return None + + guild = member.guild + active = sum(1 for ch in guild.voice_channels for member in ch.members) + mapping = { + '{mention}': member.mention, + '{user_name}': member.display_name, + '{user_avatar}': member.avatar.url if member.avatar else member.default_avatar.url, + '{guild_name}': guild.name, + '{guild_icon}': guild.icon.url if guild.icon else member.default_avatar.url, + '{studying_count}': str(active), + '{member_count}': len(guild.members), + } + + recurse_map( + lambda loc, value: replace_multiple(value, mapping) if isinstance(value, str) else value, + data_dict, + ) + + return data_dict + return formatter + + async def editor_callback(self, editor_data): + self.value = editor_data + await self.write() + + def _desc_table(self) -> list[str]: + lines = super()._desc_table() + t = ctx_translator.get().t + keydescs = [ + (key, t(value)) for key, value in self._subkey_desc.items() + ] + keytable = tabulate(*keydescs, colon='') + expline = t(_p( + 'guildset:greeting_message|embed_field|formatkeys|explanation', + "The following placeholders will be substituted with their values." + )) + keyfield = ( + t(_p('guildset:greeting_message|embed_field|formatkeys|name', "Placeholders")), + expline + '\n' + '\n'.join(f"> {line}" for line in keytable) + ) + lines.append(keyfield) + return lines + + class ReturningMessage(ModelData, MessageSetting): + setting_id = 'returning_message' + + _display_name = _p( + 'guildset:returning_message', "returning_message" + ) + _desc = _p( + 'guildset:returning_message|desc', + "Custom message used to greet returning members when they rejoin the server." + ) + _long_desc = _p( + 'guildset:returning_message|long_desc', + "When set, this message will be sent to the `welcome_channel` when a member *returns* to the server. " + "If not set, no message will be sent." + ) + _accepts = _p( + 'guildset:returning_message|accepts', + "JSON formatted returning message data" + ) + _soft_default = _p( + 'guildset:returning_message|default', + r""" + { + "embed": { + "title": "Welcome Back {user_name}!", + "thumbnail": {"url": "{User_avatar}"}, + "description": "Welcome back to **{guild_name}**!\nYou were last seen .", + "color": 15695665 + } + } + """ + ) + + _model = CoreData.Guild + _column = CoreData.Guild.returning_message.name + + _subkey_desc_returning = { + '{last_time}': _p('guildset:returning_message|formatkey:last_time', + "Unix timestamp of the last time the member was seen in the server.") + } + _subkey_desc = _greeting_subkey_desc | _subkey_desc_returning + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if value is None: + resp = t(_p( + 'guildset:returning_message|set_response:unset', + "Returning member greeting unset! Will use `welcome_message` if set." + )) + else: + resp = t(_p( + 'guildset:greeting_message|set_response:set', + "The returning member greeting has been updated." + )) + return resp + + @classmethod + def _format_data(cls, parent_id, data, **kwargs): + t = ctx_translator.get().t + if data is not None: + return super()._format_data(parent_id, data, **kwargs) + else: + return t(_p( + 'guildset:greeting_message|formmatted:unset', + "Not set, will use the `welcome_message` if set." + )) + + @classmethod + async def generate_formatter(cls, bot: LionBot, + member: discord.Member, last_seen: Optional[int], + **kwargs): + """ + Generate a formatter function for this message from the given context. + + The formatter function both accepts and returns a message data dict. + """ + async def formatter(data_dict: Optional[dict[str, Any]]): + if not data_dict: + return None + + guild = member.guild + active = sum(1 for ch in guild.voice_channels for member in ch.members) + mapping = { + '{mention}': member.mention, + '{user_name}': member.display_name, + '{user_avatar}': member.avatar.url if member.avatar else member.default_avatar.url, + '{guild_name}': guild.name, + '{guild_icon}': guild.icon.url if guild.icon else member.default_avatar.url, + '{studying_count}': str(active), + '{member_count}': str(len(guild.members)), + '{last_time}': str(last_seen or member.joined_at.timestamp()), + } + + recurse_map( + lambda loc, value: replace_multiple(value, mapping) if isinstance(value, str) else value, + data_dict, + ) + + return data_dict + return formatter + + async def editor_callback(self, editor_data): + self.value = editor_data + await self.write() + + def _desc_table(self) -> list[str]: + lines = super()._desc_table() + t = ctx_translator.get().t + keydescs = [ + (key, t(value)) for key, value in self._subkey_desc_returning.items() + ] + keytable = tabulate(*keydescs, colon='') + expline = t(_p( + 'guildset:returning_message|embed_field|formatkeys|explanation', + "In *addition* to the placeholders supported by `welcome_message`" + )) + keyfield = ( + t(_p('guildset:returning_message|embed_field|formatkeys|', "Placeholders")), + expline + '\n' + '\n'.join(f"> {line}" for line in keytable) + ) + lines.append(keyfield) + return lines + + + class Autoroles(ListData, RoleListSetting): + setting_id = 'autoroles' + + _display_name = _p( + 'guildset:autoroles', "autoroles" + ) + _desc = _p( + 'guildset:autoroles|desc', + "Roles given to new members when they join the server." + ) + _long_desc = _p( + 'guildset:autoroles|long_desc', + "These roles will be given when a member joins the server. " + "If `role_persistence` is enabled, these roles will *not* be given to a returning member." + ) + + _table_interface = MemberAdminData.autoroles + _id_column = 'guildid' + _data_column = 'roleid' + _order_column = 'roleid' + + + class BotAutoroles(ListData, RoleListSetting): + setting_id = 'bot_autoroles' + + _display_name = _p( + 'guildset:bot_autoroles', "bot_autoroles" + ) + _desc = _p( + 'guildset:bot_autoroles|desc', + "Roles given to new bots when they join the server." + ) + _long_desc = _p( + 'guildset:bot_autoroles|long_desc', + "These roles will be given when a bot joins the server." + ) + + + _table_interface = MemberAdminData.bot_autoroles + _id_column = 'guildid' + _data_column = 'roleid' + _order_column = 'roleid' + + class RolePersistence(ModelData, BoolSetting): + setting_id = 'role_persistence' + _event = 'guildset_role_persistence' + + _display_name = _p('guildset:role_persistence', "role_persistence") + _desc = _p( + 'guildset:role_persistence|desc', + "Whether member roles should be restored on rejoin." + ) + _long_desc = _p( + 'guildset:role_persistence|long_desc', + "If enabled, member roles will be stored when they leave the server, " + "and then restored when they rejoin (instead of giving `autoroles`). " + "Note that this may conflict with other bots who manage join roles." + ) + _default = True + + _model = CoreData.Guild + _column = CoreData.Guild.persist_roles.name + + @property + def update_message(self) -> str: + t = ctx_translator.get().t + value = self.value + if not value: + resp = t(_p( + 'guildset:role_persistence|set_response:off', + "Roles will not be restored when members rejoin." + )) + else: + resp = t(_p( + 'guildset:greeting_message|set_response:on', + "Roles will now be restored when members rejoin." + )) + return resp + + guild_model_settings = ( + GreetingChannel, + GreetingMessage, + ReturningMessage, + RolePersistence, + ) diff --git a/src/modules/member_admin/settingui.py b/src/modules/member_admin/settingui.py new file mode 100644 index 00000000..342b1775 --- /dev/null +++ b/src/modules/member_admin/settingui.py @@ -0,0 +1,258 @@ +import asyncio + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, RoleSelect, ChannelSelect + +from meta import LionBot + +from utils.ui import ConfigUI, DashboardSection +from utils.lib import MessageArgs +from utils.ui.msgeditor import MsgEditor +from wards import equippable_role + +from .settings import MemberAdminSettings as Settings +from . import babel + +_p = babel._p + + +class MemberAdminUI(ConfigUI): + setting_classes = ( + Settings.GreetingChannel, + Settings.GreetingMessage, + Settings.ReturningMessage, + Settings.Autoroles, + Settings.BotAutoroles, + Settings.RolePersistence, + ) + + def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs): + self.settings = bot.get_cog('MemberAdminCog').settings + super().__init__(bot, guildid, channelid, **kwargs) + + # ----- UI Components ----- + # Greeting Channel + @select( + cls=ChannelSelect, + channel_types=[discord.ChannelType.voice, discord.ChannelType.text], + placeholder="GREETCH_MENU_PLACEHOLDER", + min_values=0, max_values=1 + ) + async def greetch_menu(self, selection: discord.Interaction, selected: ChannelSelect): + """ + Selector for the `greeting_channel` setting. + """ + await selection.response.defer(thinking=True, ephemeral=True) + setting = self.get_instance(Settings.GreetingChannel) + setting.value = selected.values[0] if selected.values else None + await setting.write() + + async def greetch_menu_refresh(self): + menu = self.greetch_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:memberadmin|menu:greetch|placeholder', + "Select Greeting Channel" + )) + + # Autoroles + @select( + cls=RoleSelect, + placeholder="AUTOROLES_MENU_PLACEHOLDER", + min_values=0, max_values=25 + ) + async def autoroles_menu(self, selection: discord.Interaction, selected: RoleSelect): + """ + Simple multi-role selector for the 'autoroles' setting. + """ + await selection.response.defer(thinking=True, ephemeral=True) + for role in selected.values: + # Check authority to set these roles (for author and client) + await equippable_role(self.bot, role, selection.user) + + setting = self.get_instance(Settings.Autoroles) + setting.value = selected.values + await setting.write() + # Instance hooks will update the menu + await selection.delete_original_response() + + async def autoroles_menu_refresh(self): + menu = self.autoroles_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:memberadmin|menu:autoroles|placeholder', + "Select Autoroles" + )) + + # Bot autoroles + @select( + cls=RoleSelect, + placeholder="BOT_AUTOROLES_MENU_PLACEHOLDER", + ) + async def bot_autoroles_menu(self, selection: discord.Interaction, selected: RoleSelect): + """ + Simple multi-role selector for the 'bot_autoroles' setting. + """ + await selection.response.defer(thinking=True, ephemeral=True) + for role in selected.values: + # Check authority to set these roles (for author and client) + await equippable_role(self.bot, role, selection.user) + + setting = self.get_instance(Settings.BotAutoroles) + setting.value = selected.values + await setting.write() + # Instance hooks will update the menu + await selection.delete_original_response() + + async def bot_autoroles_menu_refresh(self): + menu = self.bot_autoroles_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:memberadmin|menu:bot_autoroles|placeholder', + "Select Bot Autoroles" + )) + + # Greeting Msg + @button( + label="GREET_MSG_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def greet_msg_button(self, press: discord.Interaction, pressed: Button): + """ + Message Editor Button for the `greeting_message` setting. + + This will open up a Message Editor with the current `greeting_message`, + if set, otherwise a default `greeting_message`. + This also generates a preview formatter using the calling user. + """ + await press.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + setting = self.get_instance(Settings.GreetingMessage) + + value = setting.value + if value is None: + value = setting._data_to_value(self.guildid, t(setting._soft_default)) + setting.value = value + await setting.write() + + editor = MsgEditor( + self.bot, + value, + callback=setting.editor_callback, + formatter=await setting.generate_formatter(self.bot, press.user), + callerid=press.user.id, + ) + self._slaves.append(editor) + await editor.run(press) + + async def greet_msg_button_refresh(self): + button = self.greet_msg_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:member_admin|button:greet_msg|label', + "Greeting Msg" + )) + + # Returning Msg + @button( + label="RETURN_MSG_BUTTON_PLACEHOLDER", + style=ButtonStyle.blurple + ) + async def return_msg_button(self, press: discord.Interaction, pressed: Button): + """ + Message Editor Button for the `returning_message` setting. + + Similar to the `greet_msg_button`, this opens a Message Editor + with the current `returning_message`. + If the setting is unset, will instead either use the current `greeting_message`, + or if that is also unset, use a default `returning_message`. + """ + await press.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + setting = self.get_instance(Settings.ReturningMessage) + greeting = self.get_instance(Settings.GreetingMessage) + + value = setting.value + if value is not None: + pass + elif greeting.value is not None: + value = greeting.value + else: + value = setting._data_to_value(self.guildid, t(setting._soft_default)) + setting.value = value + await setting.write() + + editor = MsgEditor( + self.bot, + value, + callback=setting.editor_callback, + formatter=await setting.generate_formatter( + self.bot, press.user, press.user.joined_at.timestamp() + ), + callerid=press.user.id, + ) + self._slaves.append(editor) + await editor.run(press) + + async def return_msg_button_refresh(self): + button = self.return_msg_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:memberadmin|button:return_msg|label', + "Returning Msg" + )) + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + title = t(_p( + 'ui:memberadmin|embed|title', + "Member Admin Configuration Panel" + )) + embed = discord.Embed( + title=title, + colour=discord.Colour.orange() + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + + return MessageArgs(embed=embed) + + async def reload(self): + # Re-fetch data for each instance + # This should generally hit cache + self.instances = [ + await setting.get(self.guildid) + for setting in self.setting_classes + ] + + async def refresh_components(self): + component_refresh = ( + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + self.greetch_menu_refresh(), + self.autoroles_menu_refresh(), + self.bot_autoroles_menu_refresh(), + self.greet_msg_button_refresh(), + self.return_msg_button_refresh(), + ) + await asyncio.gather(*component_refresh) + + self.set_layout( + (self.greetch_menu,), + (self.autoroles_menu,), + (self.bot_autoroles_menu,), + (self.greet_msg_button, self.return_msg_button, + self.edit_button, self.reset_button, self.close_button), + ) + + +class MemberAdminDashboard(DashboardSection): + section_name = _p( + "dash:member_admin|title", + "Greetings and Initial Roles ({commands[configure welcome]})" + ) + configui = MemberAdminUI + setting_classes = MemberAdminUI.setting_classes diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index e030b76a..d5f4d040 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -816,7 +816,7 @@ class ScheduleCog(LionCog): # Configuration @LionCog.placeholder_group - @cmds.hybrid_group('configre', with_app_command=False) + @cmds.hybrid_group('configure', with_app_command=False) async def configure_group(self, ctx: LionContext): """ Substitute configure command group. diff --git a/src/modules/schedule/ui/settingui.py b/src/modules/schedule/ui/settingui.py index a5ea69b6..3eef22d9 100644 --- a/src/modules/schedule/ui/settingui.py +++ b/src/modules/schedule/ui/settingui.py @@ -231,3 +231,41 @@ class ScheduleDashboard(DashboardSection): ) configui = ScheduleSettingUI setting_classes = ScheduleSettingUI.setting_classes + + def apply_to(self, page: discord.Embed): + t = self.bot.translator.t + pages = [ + self.instances[0:3], + self.instances[3:7], + self.instances[7:] + ] + # Schedule Channels + table = self._make_table(pages[0]) + page.add_field( + name=t(_p( + 'dash:schedule|section:schedule_channels|name', + "Scheduled Session Channels ({commands[configure schedule]})", + )).format(commands=self.bot.core.mention_cache), + value=table, + inline=False + ) + # Schedule Rewards + table = self._make_table(pages[1]) + page.add_field( + name=t(_p( + 'dash:schedule|section:schedule_rewards|name', + "Scheduled Session Rewards ({commands[configure schedule]})", + )).format(commands=self.bot.core.mention_cache), + value=table, + inline=False + ) + # Schedule Blacklist + table = self._make_table(pages[2]) + page.add_field( + name=t(_p( + 'dash:schedule|section:schedule_blacklist|name', + "Scheduled Session Blacklist ({commands[configure schedule]})", + )).format(commands=self.bot.core.mention_cache), + value=table, + inline=False + ) diff --git a/src/modules/statistics/settings.py b/src/modules/statistics/settings.py index 9d01bd73..fb8e194b 100644 --- a/src/modules/statistics/settings.py +++ b/src/modules/statistics/settings.py @@ -16,7 +16,7 @@ from meta import conf, LionBot from meta.context import ctx_bot from meta.errors import UserInputError from utils.lib import tabulate, utc_now -from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI +from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI, DashboardSection from utils.lib import MessageArgs from core.data import CoreData from core.lion_guild import VoiceMode @@ -388,3 +388,12 @@ class StatisticsConfigUI(ConfigUI): for setting in self.instances: embed.add_field(**setting.embed_field, inline=False) return MessageArgs(embed=embed) + + +class StatisticsDashboard(DashboardSection): + section_name = _p( + 'dash:stats|title', + "Activity Statistics Configuration ({commands[configure statistics]})" + ) + configui = StatisticsConfigUI + setting_classes = StatisticsConfigUI.setting_classes diff --git a/src/settings/ui.py b/src/settings/ui.py index b9e2269b..046a961b 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -455,7 +455,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): raise NotImplementedError @classmethod - def _check_value(cls, parent_id, value, **kwargs) -> Optional[str]: + async def _check_value(cls, parent_id, value, **kwargs) -> Optional[str]: """ Check the provided value is valid. diff --git a/src/utils/lib.py b/src/utils/lib.py index e495019f..16d89135 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -1,4 +1,5 @@ from typing import NamedTuple, Optional, Sequence, Union, overload, List +import collections import datetime import iso8601 # type: ignore import pytz @@ -788,3 +789,18 @@ def emojikey(emoji: discord.Emoji | discord.PartialEmoji | str): key = str(emoji) return key + +def recurse_map(func, obj, loc=[]): + if isinstance(obj, dict): + for k, v in obj.items(): + loc.append(k) + obj[k] = recurse_map(func, v, loc) + loc.pop() + elif isinstance(obj, list): + for i, item in enumerate(obj): + loc.append(i) + obj[i] = recurse_map(func, item) + loc.pop() + else: + obj = func(loc, obj) + return obj diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index 06504655..6c118622 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -56,6 +56,10 @@ class ConfigUI(LeoUI): def page_instances(self): return self.instances + def get_instance(self, setting_cls): + setting_id = setting_cls.setting_id + return next(instance for instance in self.instances if instance.setting_id == setting_id) + async def interaction_check(self, interaction: discord.Interaction): """ Default requirement for a Config UI is low management (i.e. manage_guild permissions). @@ -102,7 +106,7 @@ class ConfigUI(LeoUI): instances = self.page_instances items = [setting.input_field for setting in instances] # Filter out settings which don't have input fields - items = [item for item in items if item] + items = [item for item in items if item][:5] strings = [item.value for item in items] modal = ConfigEditor(*items, title=t(self.edit_modal_title)) @@ -313,8 +317,11 @@ class DashboardSection: ) def make_table(self): + return self._make_table(self.instances) + + def _make_table(self, instances): rows = [] - for setting in self.instances: + for setting in instances: name = setting.display_name value = setting.formatted rows.append((name, value, setting.desc)) diff --git a/src/utils/ui/msgeditor.py b/src/utils/ui/msgeditor.py index 62c0e1dc..c22ee60f 100644 --- a/src/utils/ui/msgeditor.py +++ b/src/utils/ui/msgeditor.py @@ -12,8 +12,6 @@ from discord.ui.text_input import TextInput, TextStyle from meta import conf, LionBot from meta.errors import UserInputError, ResponseTimedOut -from utils.lib import error_embed -from babel.translator import ctx_translator, LazyStr from ..lib import MessageArgs, utc_now @@ -49,8 +47,14 @@ class MsgEditor(MessageUI): # ----- API ----- async def format_data(self, data): + """ + Format a MessageData dict for rendering. + + May be extended or overridden for custom formatting. + By default, uses the provided `formatter` callback (if provided). + """ if self._formatter is not None: - self._formatter(data) + await self._formatter(data) def copy_data(self): return copy.deepcopy(self.history[-1]) @@ -567,12 +571,13 @@ class MsgEditor(MessageUI): style=TextStyle.short, required=True, max_length=256, + default='True', ) modal = MsgEditorInput( - position_field, name_field, value_field, + position_field, inline_field, title=t(_p('ui:msg_editor|modal:add_field|title', "Add Embed Field")) ) @@ -1005,6 +1010,7 @@ class MsgEditor(MessageUI): async def cont_button(interaction: discord.Interaction, pressed): await interaction.response.defer() await interaction.message.delete() + nonlocal stopped stopped = True # TODO: Clean up this mess. It works, but needs to be refactored to a timeout confirmation mixin. # TODO: Consider moving the message to the interaction response