rewrite: New 'Member Admin' module.

This commit is contained in:
2023-08-13 08:10:39 +03:00
parent 66e0641aab
commit 2eece69760
16 changed files with 1183 additions and 16 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -14,6 +14,7 @@ active = [
'.pomodoro',
'.rooms',
'.rolemenus',
'.member_admin',
'.meta',
'.test',
]

View File

@@ -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,),
]

View File

@@ -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))

View File

@@ -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 <uid:{member.id}> in <gid:{member.guild.id}>: "
f"{e.text}"
)
else:
logger.debug(
f"Welcome message sent to <uid:{member.id}> in <gid:{member.guild.id}>."
)
# 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 <uid:{member.id}> in <gid:{member.guild.id}>: {e.text}"
)
else:
logger.debug(
f"Gave autoroles to <uid:{member.id}> in <gid:{member.guild.id}>"
)
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 <uid:{member.id}> in <gid:{member.guild.id}>: "
f"{e.text}"
)
else:
logger.debug(
f"Returning message sent to <uid:{member.id}> in <gid:{member.guild.id}>."
)
# 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 <uid:{member.id}> in <gid:{member.guild.id}>: {e.text}"
)
else:
logger.debug(
f"Restored roles to <uid:{member.id}> in <gid:{member.guild.id}>"
)
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 <uid:{member.id}> in <gid:{member.guild.id}>: {e.text}"
)
else:
logger.debug(
f"Gave autoroles to <uid:{member.id}> in <gid:{member.guild.id}>"
)
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 <uid:{member.id}> in <gid:{member.guild.id}>."
)
@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 <gid:{guild.id}> 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 <gid:{guildid}> 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()

View File

@@ -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')

View File

@@ -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 <t:{last_time}:R>.",
"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,
)

View File

@@ -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

View File

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

View File

@@ -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
)

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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))

View File

@@ -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