rewrite: New 'Member Admin' module.
This commit is contained in:
@@ -108,5 +108,7 @@ class LionMember(Timezoned):
|
|||||||
# TODO: Logging, audit logging
|
# TODO: Logging, audit logging
|
||||||
pass
|
pass
|
||||||
else:
|
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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
from discord.enums import TextStyle
|
||||||
|
|
||||||
from settings.base import ParentID
|
from settings.base import ParentID
|
||||||
from settings.setting_types import IntegerSetting, StringSetting
|
from settings.setting_types import IntegerSetting, StringSetting
|
||||||
@@ -13,6 +14,7 @@ from meta import conf
|
|||||||
from meta.errors import UserInputError
|
from meta.errors import UserInputError
|
||||||
from constants import MAX_COINS
|
from constants import MAX_COINS
|
||||||
from babel.translator import ctx_translator
|
from babel.translator import ctx_translator
|
||||||
|
from utils.lib import MessageArgs
|
||||||
|
|
||||||
from . import babel
|
from . import babel
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ class MessageSetting(StringSetting):
|
|||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
@classmethod
|
@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:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -133,6 +135,8 @@ class MessageSetting(StringSetting):
|
|||||||
embeds = []
|
embeds = []
|
||||||
for embed_data in value['embeds']:
|
for embed_data in value['embeds']:
|
||||||
embeds.append(discord.Embed.from_dict(embed_data))
|
embeds.append(discord.Embed.from_dict(embed_data))
|
||||||
|
args['embeds'] = embeds
|
||||||
|
return MessageArgs(**args)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs):
|
def _data_from_value(cls, parent_id: ParentID, value: Optional[dict], **kwargs):
|
||||||
@@ -268,3 +272,9 @@ class MessageSetting(StringSetting):
|
|||||||
formatted = content
|
formatted = content
|
||||||
|
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_field(self):
|
||||||
|
field = super().input_field
|
||||||
|
field.style = TextStyle.long
|
||||||
|
return field
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ active = [
|
|||||||
'.pomodoro',
|
'.pomodoro',
|
||||||
'.rooms',
|
'.rooms',
|
||||||
'.rolemenus',
|
'.rolemenus',
|
||||||
|
'.member_admin',
|
||||||
'.meta',
|
'.meta',
|
||||||
'.test',
|
'.test',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ from modules.pomodoro.settingui import TimerDashboard
|
|||||||
from modules.rooms.settingui import RoomDashboard
|
from modules.rooms.settingui import RoomDashboard
|
||||||
from babel.settingui import LocaleDashboard
|
from babel.settingui import LocaleDashboard
|
||||||
from modules.schedule.ui.settingui import ScheduleDashboard
|
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
|
from . import babel, logger
|
||||||
|
|
||||||
@@ -30,9 +32,9 @@ class GuildDashboard(BasePager):
|
|||||||
Paged UI providing an overview of the guild configuration.
|
Paged UI providing an overview of the guild configuration.
|
||||||
"""
|
"""
|
||||||
pages = [
|
pages = [
|
||||||
(LocaleDashboard, EconomyDashboard, TasklistDashboard),
|
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
|
||||||
(VoiceTrackerDashboard, TextTrackerDashboard, ),
|
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
||||||
(RankDashboard, TimerDashboard, RoomDashboard, ),
|
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
||||||
(ScheduleDashboard,),
|
(ScheduleDashboard,),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
10
src/modules/member_admin/__init__.py
Normal file
10
src/modules/member_admin/__init__.py
Normal 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))
|
||||||
382
src/modules/member_admin/cog.py
Normal file
382
src/modules/member_admin/cog.py
Normal 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()
|
||||||
7
src/modules/member_admin/data.py
Normal file
7
src/modules/member_admin/data.py
Normal 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')
|
||||||
419
src/modules/member_admin/settings.py
Normal file
419
src/modules/member_admin/settings.py
Normal 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,
|
||||||
|
)
|
||||||
258
src/modules/member_admin/settingui.py
Normal file
258
src/modules/member_admin/settingui.py
Normal 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
|
||||||
@@ -816,7 +816,7 @@ class ScheduleCog(LionCog):
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@LionCog.placeholder_group
|
@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):
|
async def configure_group(self, ctx: LionContext):
|
||||||
"""
|
"""
|
||||||
Substitute configure command group.
|
Substitute configure command group.
|
||||||
|
|||||||
@@ -231,3 +231,41 @@ class ScheduleDashboard(DashboardSection):
|
|||||||
)
|
)
|
||||||
configui = ScheduleSettingUI
|
configui = ScheduleSettingUI
|
||||||
setting_classes = ScheduleSettingUI.setting_classes
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from meta import conf, LionBot
|
|||||||
from meta.context import ctx_bot
|
from meta.context import ctx_bot
|
||||||
from meta.errors import UserInputError
|
from meta.errors import UserInputError
|
||||||
from utils.lib import tabulate, utc_now
|
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 utils.lib import MessageArgs
|
||||||
from core.data import CoreData
|
from core.data import CoreData
|
||||||
from core.lion_guild import VoiceMode
|
from core.lion_guild import VoiceMode
|
||||||
@@ -388,3 +388,12 @@ class StatisticsConfigUI(ConfigUI):
|
|||||||
for setting in self.instances:
|
for setting in self.instances:
|
||||||
embed.add_field(**setting.embed_field, inline=False)
|
embed.add_field(**setting.embed_field, inline=False)
|
||||||
return MessageArgs(embed=embed)
|
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
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@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.
|
Check the provided value is valid.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import NamedTuple, Optional, Sequence, Union, overload, List
|
from typing import NamedTuple, Optional, Sequence, Union, overload, List
|
||||||
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
import iso8601 # type: ignore
|
import iso8601 # type: ignore
|
||||||
import pytz
|
import pytz
|
||||||
@@ -788,3 +789,18 @@ def emojikey(emoji: discord.Emoji | discord.PartialEmoji | str):
|
|||||||
key = str(emoji)
|
key = str(emoji)
|
||||||
|
|
||||||
return key
|
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
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class ConfigUI(LeoUI):
|
|||||||
def page_instances(self):
|
def page_instances(self):
|
||||||
return self.instances
|
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):
|
async def interaction_check(self, interaction: discord.Interaction):
|
||||||
"""
|
"""
|
||||||
Default requirement for a Config UI is low management (i.e. manage_guild permissions).
|
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
|
instances = self.page_instances
|
||||||
items = [setting.input_field for setting in instances]
|
items = [setting.input_field for setting in instances]
|
||||||
# Filter out settings which don't have input fields
|
# 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]
|
strings = [item.value for item in items]
|
||||||
modal = ConfigEditor(*items, title=t(self.edit_modal_title))
|
modal = ConfigEditor(*items, title=t(self.edit_modal_title))
|
||||||
|
|
||||||
@@ -313,8 +317,11 @@ class DashboardSection:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def make_table(self):
|
def make_table(self):
|
||||||
|
return self._make_table(self.instances)
|
||||||
|
|
||||||
|
def _make_table(self, instances):
|
||||||
rows = []
|
rows = []
|
||||||
for setting in self.instances:
|
for setting in instances:
|
||||||
name = setting.display_name
|
name = setting.display_name
|
||||||
value = setting.formatted
|
value = setting.formatted
|
||||||
rows.append((name, value, setting.desc))
|
rows.append((name, value, setting.desc))
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ from discord.ui.text_input import TextInput, TextStyle
|
|||||||
|
|
||||||
from meta import conf, LionBot
|
from meta import conf, LionBot
|
||||||
from meta.errors import UserInputError, ResponseTimedOut
|
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
|
from ..lib import MessageArgs, utc_now
|
||||||
|
|
||||||
@@ -49,8 +47,14 @@ class MsgEditor(MessageUI):
|
|||||||
|
|
||||||
# ----- API -----
|
# ----- API -----
|
||||||
async def format_data(self, data):
|
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:
|
if self._formatter is not None:
|
||||||
self._formatter(data)
|
await self._formatter(data)
|
||||||
|
|
||||||
def copy_data(self):
|
def copy_data(self):
|
||||||
return copy.deepcopy(self.history[-1])
|
return copy.deepcopy(self.history[-1])
|
||||||
@@ -567,12 +571,13 @@ class MsgEditor(MessageUI):
|
|||||||
style=TextStyle.short,
|
style=TextStyle.short,
|
||||||
required=True,
|
required=True,
|
||||||
max_length=256,
|
max_length=256,
|
||||||
|
default='True',
|
||||||
)
|
)
|
||||||
|
|
||||||
modal = MsgEditorInput(
|
modal = MsgEditorInput(
|
||||||
position_field,
|
|
||||||
name_field,
|
name_field,
|
||||||
value_field,
|
value_field,
|
||||||
|
position_field,
|
||||||
inline_field,
|
inline_field,
|
||||||
title=t(_p('ui:msg_editor|modal:add_field|title', "Add Embed 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):
|
async def cont_button(interaction: discord.Interaction, pressed):
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
await interaction.message.delete()
|
await interaction.message.delete()
|
||||||
|
nonlocal stopped
|
||||||
stopped = True
|
stopped = True
|
||||||
# TODO: Clean up this mess. It works, but needs to be refactored to a timeout confirmation mixin.
|
# 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
|
# TODO: Consider moving the message to the interaction response
|
||||||
|
|||||||
Reference in New Issue
Block a user