rewrite: Sysadmin module.
This commit is contained in:
15
bot/modules/sysadmin/__init__.py
Normal file
15
bot/modules/sysadmin/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .exec_cog import Exec
|
||||
from .blacklists import Blacklists
|
||||
from .guild_log import GuildLog
|
||||
from .presence import PresenceCtrl
|
||||
|
||||
from .dash import LeoSettings
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(LeoSettings(bot))
|
||||
|
||||
await bot.add_cog(Blacklists(bot))
|
||||
await bot.add_cog(Exec(bot))
|
||||
await bot.add_cog(GuildLog(bot))
|
||||
await bot.add_cog(PresenceCtrl(bot))
|
||||
667
bot/modules/sysadmin/blacklists.py
Normal file
667
bot/modules/sysadmin/blacklists.py
Normal file
@@ -0,0 +1,667 @@
|
||||
from typing import Optional, List
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from data import Table, Registry, ORDER
|
||||
|
||||
import discord
|
||||
from discord.abc import Messageable
|
||||
from discord.ext import commands as cmds
|
||||
from discord.app_commands.transformers import AppCommandOptionType
|
||||
from discord.ui.select import select, Select, SelectOption
|
||||
from discord.ui.button import button
|
||||
from discord.ui.text_input import TextStyle, TextInput
|
||||
|
||||
from meta import LionCog, LionBot, LionContext
|
||||
from meta.logger import logging_context, log_wrap
|
||||
from meta.errors import UserInputError
|
||||
from meta.app import shard_talk
|
||||
|
||||
from utils.ui import ChoicedEnum, Transformed, FastModal, LeoUI, error_handler_for, ModalRetryUI
|
||||
from utils.lib import EmbedField, tabulate, MessageArgs, parse_ids, error_embed
|
||||
|
||||
from wards import sys_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlacklistData(Registry, name="blacklists"):
|
||||
guild_blacklist = Table('global_guild_blacklist')
|
||||
user_blacklist = Table('global_user_blacklist')
|
||||
|
||||
|
||||
class BlacklistAction(ChoicedEnum):
|
||||
ADD_USER = "Blacklist Users"
|
||||
RM_USER = "UnBlacklist Users"
|
||||
ADD_GUILD = "Blacklist Guilds"
|
||||
RM_GUILD = "UnBlacklist Guilds"
|
||||
|
||||
@property
|
||||
def choice_name(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class Blacklists(LionCog):
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data = self.bot.db.load_registry(BlacklistData())
|
||||
|
||||
self.user_blacklist: set[int] = set()
|
||||
self.guild_blacklist: set[int] = set()
|
||||
|
||||
self.talk_user_blacklist = shard_talk.register_route("user blacklist")(self.load_user_blacklist)
|
||||
self.talk_guild_blacklist = shard_talk.register_route("guild blacklist")(self.load_guild_blacklist)
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
await self.load_user_blacklist()
|
||||
await self.load_guild_blacklist()
|
||||
|
||||
async def load_user_blacklist(self):
|
||||
"""Populate the user blacklist."""
|
||||
rows = await self.data.user_blacklist.select_where()
|
||||
self.user_blacklist = {row['userid'] for row in rows}
|
||||
logger.info(
|
||||
f"Loaded {len(self.user_blacklist)} blacklisted users."
|
||||
)
|
||||
|
||||
async def load_guild_blacklist(self):
|
||||
"""Populate the guild blacklist."""
|
||||
rows = await self.data.guild_blacklist.select_where()
|
||||
self.guild_blacklist = {row['guildid'] for row in rows}
|
||||
logger.info(
|
||||
f"Loaded {len(self.guild_blacklist)} blacklisted guilds."
|
||||
)
|
||||
if self.bot.is_ready():
|
||||
with logging_context(action="Guild Blacklist"):
|
||||
await self.leave_blacklisted_guilds()
|
||||
|
||||
@LionCog.listener('on_ready')
|
||||
@log_wrap(action="Guild Blacklist")
|
||||
async def leave_blacklisted_guilds(self):
|
||||
"""Leave any blacklisted guilds we are in on this shard."""
|
||||
to_leave = [
|
||||
guild for guild in self.bot.guilds
|
||||
if guild.id in self.guild_blacklist
|
||||
]
|
||||
|
||||
asyncio.gather(*(guild.leave() for guild in to_leave))
|
||||
|
||||
logger.info(
|
||||
"Left {} blacklisted guilds.".format(len(to_leave)),
|
||||
)
|
||||
|
||||
@LionCog.listener('on_guild_join')
|
||||
@log_wrap(action="Check Guild Blacklist")
|
||||
async def check_guild_blacklist(self, guild):
|
||||
"""Check if the given guild is in the blacklist, and leave if so."""
|
||||
with logging_context(context=f"gid: {guild.id}"):
|
||||
if guild.id in self.guild_blacklist:
|
||||
await guild.leave()
|
||||
logger.info(
|
||||
"Automatically left blacklisted guild '{}' (gid:{}) upon join.".format(guild.name, guild.id)
|
||||
)
|
||||
|
||||
async def bot_check_once(self, ctx: LionContext) -> bool: # type:ignore
|
||||
if ctx.author.id in self.user_blacklist:
|
||||
logger.debug(
|
||||
f"Ignoring command from blacklisted user <uid: {ctx.author.id}>.",
|
||||
extra={'action': 'User Blacklist'}
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@log_wrap(action="User Blacklist")
|
||||
async def blacklist_users(self, actorid, userids, reason):
|
||||
await self.data.user_blacklist.insert_many(
|
||||
('userid', 'ownerid', 'reason'),
|
||||
*((userid, actorid, reason) for userid in userids)
|
||||
)
|
||||
self.user_blacklist.update(userids)
|
||||
await self.talk_user_blacklist().broadcast()
|
||||
|
||||
uid_str = ', '.join(f"<uid: {userid}>" for userid in userids)
|
||||
logger.info(
|
||||
f"Owner <aid: {actorid}> blacklisted {uid_str} with reason: \"{reason}\""
|
||||
)
|
||||
|
||||
@log_wrap(action="User Blacklist")
|
||||
async def unblacklist_users(self, actorid, userids):
|
||||
await self.data.user_blacklist.delete_where(userid=userids)
|
||||
self.user_blacklist.difference_update(userids)
|
||||
|
||||
await self.talk_user_blacklist().broadcast()
|
||||
|
||||
uid_str = ', '.join(f"<uid: {userid}>" for userid in userids)
|
||||
logger.info(
|
||||
f"Owner <aid: {actorid}> removed blacklist for user(s) {uid_str}."
|
||||
)
|
||||
|
||||
@log_wrap(action="Guild Blacklist")
|
||||
async def blacklist_guilds(self, actorid, guildids, reason):
|
||||
await self.data.guild_blacklist.insert_many(
|
||||
('guildid', 'ownerid', 'reason'),
|
||||
*((guildid, actorid, reason) for guildid in guildids)
|
||||
)
|
||||
self.guild_blacklist.update(guildids)
|
||||
await self.talk_guild_blacklist().broadcast()
|
||||
|
||||
gid_str = ', '.join(f"<gid: {guildid}>" for guildid in guildids)
|
||||
logger.info(
|
||||
f"Owner <aid: {actorid}> blacklisted {gid_str} with reason: \"{reason}\""
|
||||
)
|
||||
|
||||
@log_wrap(action="Guild Blacklist")
|
||||
async def unblacklist_guilds(self, actorid, guildids):
|
||||
await self.data.guild_blacklist.delete_where(guildid=guildids)
|
||||
self.guild_blacklist.difference_update(guildids)
|
||||
|
||||
await self.talk_guild_blacklist().broadcast()
|
||||
|
||||
gid_str = ', '.join(f"<gid: {guildid}>" for guildid in guildids)
|
||||
logger.info(
|
||||
f"Owner <aid: {actorid}> removed blacklist for guild(s) {gid_str}."
|
||||
)
|
||||
await self.check_guild_blacklist()
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name="blacklist",
|
||||
description="Display and modify the user and guild blacklists."
|
||||
)
|
||||
@cmds.check(sys_admin)
|
||||
async def blacklist_cmd(
|
||||
self,
|
||||
ctx: LionContext,
|
||||
action: Optional[Transformed[BlacklistAction, AppCommandOptionType.string]] = None,
|
||||
targets: Optional[str] = None,
|
||||
reason: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Display and modify the user and guild blacklists.
|
||||
|
||||
With no arguments, just displays the Blacklist UI.
|
||||
|
||||
If `targets` are provided, they should be a comma separated list of user or guild ids.
|
||||
If `action` is not specified, they are assumed to be users to blacklist.
|
||||
`reason` is the reason for the blacklist.
|
||||
If `targets` are provided, but `reason` is not, it will be prompted for.
|
||||
"""
|
||||
UI = BlacklistUI(ctx.bot, ctx, auth=[ctx.author.id])
|
||||
if not ctx.interaction:
|
||||
return await ctx.error_reply("This command cannot be used as a text command.")
|
||||
|
||||
if (action is None and targets is not None) or action is BlacklistAction.ADD_USER:
|
||||
await UI.spawn_add_users(ctx.interaction, targets, reason)
|
||||
elif action is BlacklistAction.ADD_GUILD:
|
||||
await UI.spawn_add_guilds(ctx.interaction, targets, reason)
|
||||
elif action is BlacklistAction.RM_USER:
|
||||
if targets is None:
|
||||
UI._show_remove = True
|
||||
await UI.spawn()
|
||||
else:
|
||||
try:
|
||||
userids = parse_ids(targets)
|
||||
except UserInputError as ex:
|
||||
await ctx.error_reply("Could not extract user id from {item}".format(**ex.info))
|
||||
else:
|
||||
await UI.do_rm_users(ctx.interaction, userids)
|
||||
elif action is BlacklistAction.RM_GUILD:
|
||||
if targets is None:
|
||||
UI._show_remove = True
|
||||
UI.guild_mode = True
|
||||
await UI.spawn()
|
||||
else:
|
||||
try:
|
||||
guildids = parse_ids(targets)
|
||||
except UserInputError as ex:
|
||||
await ctx.error_reply("Could not extract guild id from {item}".format(**ex.info))
|
||||
else:
|
||||
await UI.do_rm_guilds(ctx.interaction, guildids)
|
||||
elif action is None and targets is None:
|
||||
await UI.spawn()
|
||||
|
||||
|
||||
class BlacklistInput(FastModal):
|
||||
targets: TextInput = TextInput(
|
||||
label="Userids to blacklist.",
|
||||
placeholder="Comma separated ids.",
|
||||
max_length=4000,
|
||||
required=True
|
||||
)
|
||||
|
||||
reason: TextInput = TextInput(
|
||||
label="Reason for the blacklist.",
|
||||
style=TextStyle.long,
|
||||
max_length=4000,
|
||||
required=True
|
||||
)
|
||||
|
||||
@error_handler_for(UserInputError)
|
||||
async def rerequest(self, interaction: discord.Interaction, error: UserInputError):
|
||||
await ModalRetryUI(self, error.msg).respond_to(interaction)
|
||||
|
||||
|
||||
class BlacklistUI(LeoUI):
|
||||
block_len = 5 # Number of entries to show per page
|
||||
|
||||
def __init__(self, bot: LionBot, dest: Messageable, auth: Optional[List[int]] = None):
|
||||
super().__init__()
|
||||
# Client information
|
||||
self.bot = bot
|
||||
self.cog: Blacklists = bot.get_cog('Blacklists') # type: ignore
|
||||
if self.cog is None:
|
||||
raise ValueError("Cannot run BlacklistUI without the 'Blacklists' cog.")
|
||||
|
||||
# State
|
||||
self.guild_mode = False # Whether we are showing guild blacklist or user blacklist
|
||||
# List of current pages, as (page args, data slice) tuples
|
||||
self.pages: Optional[List[tuple[MessageArgs, tuple[int, int]]]] = None
|
||||
self.page_no: int = 0 # Current page we are on
|
||||
self.data = None # List of data rows for this mode
|
||||
|
||||
# Discord State
|
||||
self.dest = dest # The destination to send or resend the UI
|
||||
self.message: Optional[discord.Message] = None # Message holding the UI
|
||||
|
||||
# UI State
|
||||
# This is better handled by a general abstract "_extra" or layout modi interface.
|
||||
# For now, just a flag for whether we show the extra remove menu.
|
||||
self._show_remove = False
|
||||
self.auth = auth # List of userids authorised to use the UI
|
||||
|
||||
async def interaction_check(self, interaction):
|
||||
if self.auth and interaction.user.id not in self.auth:
|
||||
await interaction.response.send_message(
|
||||
embed=error_embed("You are not authorised to use this interface!"),
|
||||
ephemeral=True
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def cleanup(self):
|
||||
if self.message is not None:
|
||||
try:
|
||||
await self.message.edit(view=None)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@button(label="ADD", row=2)
|
||||
async def press_add(self, interaction, pressed):
|
||||
if self.guild_mode:
|
||||
await self.spawn_add_guilds(interaction)
|
||||
else:
|
||||
await self.spawn_add_users(interaction)
|
||||
|
||||
@button(label="RM", row=2)
|
||||
async def press_rm(self, interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
self._show_remove = not self._show_remove
|
||||
await self.show()
|
||||
|
||||
@button(label="Switch", row=2)
|
||||
async def press_switch(self, interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
if self.guild_mode:
|
||||
await self.set_user_mode()
|
||||
else:
|
||||
await self.set_guild_mode()
|
||||
|
||||
@button(label="<", row=1)
|
||||
async def press_previous(self, interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
self.page_no -= 1
|
||||
await self.show()
|
||||
|
||||
@button(label="x", row=1)
|
||||
async def press_cancel(self, interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
if self.message:
|
||||
try:
|
||||
await self.message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await self.close()
|
||||
|
||||
@button(label=">", row=1)
|
||||
async def press_next(self, interaction, pressed):
|
||||
await interaction.response.defer()
|
||||
self.page_no += 1
|
||||
await self.show()
|
||||
|
||||
@select(cls=Select, max_values=block_len)
|
||||
async def select_remove(self, interaction, selected):
|
||||
self._show_remove = False
|
||||
if not selected.values:
|
||||
# Treat this as a cancel
|
||||
await interaction.response.defer()
|
||||
else:
|
||||
# Parse the values and pass straight to the appropriate do method
|
||||
# Aside from race states, should be impossible for this to raise a handled exception
|
||||
# (So no need to catch UserInputError)
|
||||
ids = map(int, selected.values)
|
||||
if self.guild_mode:
|
||||
await self.do_rm_guilds(interaction, ids)
|
||||
else:
|
||||
await self.do_rm_users(interaction, ids)
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
if not self.pages:
|
||||
raise ValueError("Cannot get the current page without pages!")
|
||||
self.page_no %= len(self.pages)
|
||||
return self.pages[self.page_no]
|
||||
|
||||
async def spawn(self):
|
||||
"""
|
||||
Run the UI.
|
||||
"""
|
||||
if self.guild_mode:
|
||||
await self.set_guild_mode()
|
||||
else:
|
||||
await self.set_user_mode()
|
||||
|
||||
async def update_data(self):
|
||||
"""
|
||||
Updated stored data for the current mode.
|
||||
"""
|
||||
if self.guild_mode:
|
||||
query = self.cog.data.guild_blacklist.select_where()
|
||||
query.leftjoin('guild_config', using=('guildid',))
|
||||
query.select('guildid', 'ownerid', 'reason', 'name', 'created_at')
|
||||
else:
|
||||
query = self.cog.data.user_blacklist.select_where()
|
||||
query.leftjoin('user_config', using=('userid',))
|
||||
query.select('userid', 'ownerid', 'reason', 'name', 'created_at')
|
||||
|
||||
query.order_by('created_at', ORDER.DESC)
|
||||
self.data = await query
|
||||
return self.data
|
||||
|
||||
async def set_guild_mode(self):
|
||||
"""
|
||||
Change UI to guild blacklist mode.
|
||||
"""
|
||||
self.guild_mode = True
|
||||
self.press_add.label = "Blacklist Guilds"
|
||||
self.press_rm.label = "Un-Blacklist Guilds"
|
||||
self.press_switch.label = "Show User List"
|
||||
self.select_remove.placeholder = "Select User id to remove"
|
||||
|
||||
if not self.guild_mode:
|
||||
self._show_remove = False
|
||||
|
||||
self.page_no = 0
|
||||
await self.refresh()
|
||||
|
||||
async def set_user_mode(self):
|
||||
"""
|
||||
Change UI to user blacklist mode.
|
||||
"""
|
||||
self.press_add.label = "Blacklist Users"
|
||||
self.press_rm.label = "Un-Blacklist Users"
|
||||
self.press_switch.label = "Show Guild List"
|
||||
self.select_remove.placeholder = "Select Guild id to remove"
|
||||
|
||||
if self.guild_mode:
|
||||
self._show_remove = False
|
||||
|
||||
self.guild_mode = False
|
||||
self.page_no = 0
|
||||
await self.refresh()
|
||||
|
||||
async def show(self):
|
||||
"""
|
||||
Show the Blacklist UI, creating a new message if required.
|
||||
"""
|
||||
if len(self.pages) > 1:
|
||||
self.set_layout(
|
||||
(self.press_previous, self.press_cancel, self.press_next),
|
||||
(self.press_add, self.press_rm, self.press_switch)
|
||||
)
|
||||
else:
|
||||
self.set_layout(
|
||||
(self.press_add, self.press_rm, self.press_switch, self.press_cancel)
|
||||
)
|
||||
page, slice = self.current_page
|
||||
if self._show_remove and self.data:
|
||||
key = 'guildid' if self.guild_mode else 'userid'
|
||||
self.select_remove._underlying.options = [
|
||||
SelectOption(label=str(row[key]), value=str(row[key]))
|
||||
for row in self.data[slice[0]:slice[1]]
|
||||
]
|
||||
self.set_layout(*self._layout, (self.select_remove,))
|
||||
|
||||
self.press_rm.disabled = (not self.data)
|
||||
|
||||
if self.message is not None:
|
||||
self.message = await self.message.edit(**page.edit_args, view=self)
|
||||
else:
|
||||
self.message = await self.dest.send(**page.send_args, view=self)
|
||||
|
||||
def format_user_rows(self, *rows):
|
||||
fields = []
|
||||
for row in rows:
|
||||
userid = row['userid']
|
||||
name = row['name']
|
||||
if user := self.bot.get_user(userid):
|
||||
name = f"({user.name})"
|
||||
elif oldname := row['name']:
|
||||
name = f"({oldname})"
|
||||
else:
|
||||
name = ''
|
||||
reason = row['reason']
|
||||
if len(reason) > 900:
|
||||
reason = reason[:900] + '...'
|
||||
table = '\n'.join(tabulate(
|
||||
("User", f"<@{userid}> {name}"),
|
||||
("Blacklisted by", f"<@{row['ownerid']}>"),
|
||||
("Blacklisted at", f"<t:{int(row['created_at'].timestamp())}:F>"),
|
||||
("Reason", reason)
|
||||
))
|
||||
fields.append(EmbedField(name=str(userid), value=table, inline=False))
|
||||
return fields
|
||||
|
||||
def format_guild_rows(self, *rows):
|
||||
fields = []
|
||||
for row in rows:
|
||||
guildid = row['guildid']
|
||||
|
||||
name = row['name']
|
||||
if guild := self.bot.get_guild(guildid):
|
||||
name = f"({guild.name})"
|
||||
elif oldname := row['name']:
|
||||
name = f"({oldname})"
|
||||
else:
|
||||
name = ''
|
||||
|
||||
reason = row['reason']
|
||||
table = '\n'.join(tabulate(
|
||||
("Guild", f"`{guildid}` {name}"),
|
||||
("Blacklisted by", f"<@{row['ownerid']}>"),
|
||||
("Blacklisted at", f"<t:{int(row['created_at'].timestamp())}:F>"),
|
||||
("Reason", reason)
|
||||
))
|
||||
fields.append(EmbedField(name=str(guildid), value=table, inline=False))
|
||||
return fields
|
||||
|
||||
async def make_pages(self):
|
||||
"""
|
||||
Format the data in `self.data`, respecting the current mode.
|
||||
"""
|
||||
if self.data is None:
|
||||
raise ValueError("Cannot make pages without initialising first!")
|
||||
|
||||
embeds = []
|
||||
slices = []
|
||||
if self.guild_mode:
|
||||
title = "Guild Blacklist"
|
||||
no_desc = "There are no blacklisted guilds"
|
||||
formatter = self.format_guild_rows
|
||||
else:
|
||||
title = "User Blacklist"
|
||||
no_desc = "There are no blacklisted users"
|
||||
formatter = self.format_user_rows
|
||||
|
||||
base_embed = discord.Embed(
|
||||
title=title,
|
||||
colour=discord.Colour.dark_orange()
|
||||
)
|
||||
if len(self.data) == 0:
|
||||
base_embed.description = no_desc
|
||||
embeds.append(base_embed)
|
||||
slices.append((0, 0))
|
||||
else:
|
||||
fields = formatter(*self.data)
|
||||
bl = self.block_len
|
||||
blocks = [(fields[i:i+bl], (i, i+bl)) for i in range(0, len(fields), bl)]
|
||||
n = len(blocks)
|
||||
for i, (block, slice) in enumerate(blocks):
|
||||
embed = base_embed.copy()
|
||||
embed._fields = [field._asdict() for field in block]
|
||||
if n > 1:
|
||||
embed.title += f" (Page {i + 1}/{n})"
|
||||
embeds.append(embed)
|
||||
slices.append(slice)
|
||||
|
||||
pages = [MessageArgs(embed=embed) for embed in embeds]
|
||||
self.pages = list(zip(pages, slices))
|
||||
return self.pages
|
||||
|
||||
async def refresh(self):
|
||||
"""
|
||||
Refresh the current UI message, if it exists.
|
||||
Takes into account the current mode and page number.
|
||||
"""
|
||||
await self.update_data()
|
||||
await self.make_pages()
|
||||
await self.show()
|
||||
|
||||
async def spawn_add_users(self, interaction: discord.Interaction,
|
||||
userids: Optional[str] = None, reason: Optional[str] = None):
|
||||
"""Spawn the add_users modal, optionally with fields pre-filled."""
|
||||
modal = BlacklistInput(title="Blacklist users")
|
||||
modal.targets.default = userids
|
||||
modal.reason.default = reason
|
||||
|
||||
@modal.submit_callback()
|
||||
async def add_users_submit(interaction):
|
||||
await self.parse_add_users(interaction, modal.targets.value, modal.reason.value)
|
||||
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def parse_add_users(self, interaction, useridstr: str, reason: str):
|
||||
"""
|
||||
Parse provided userid string and reason, and pass onto do_add_users.
|
||||
If they are invalid, instead raise a UserInputError.
|
||||
"""
|
||||
try:
|
||||
userids = parse_ids(useridstr)
|
||||
except UserInputError as ex:
|
||||
raise UserInputError("Could not extract a user id from `$item`", info=ex.info) from None
|
||||
|
||||
await self.do_add_users(interaction, userids, reason)
|
||||
|
||||
async def do_add_users(self, interaction: discord.Interaction, userids: list[int], reason: str):
|
||||
"""
|
||||
Actually blacklist the given users and send an ack.
|
||||
To be run after initial argument validation.
|
||||
Updates the UI, or posts one if it doesn't exist.
|
||||
"""
|
||||
remaining = set(userids).difference(self.cog.user_blacklist)
|
||||
if not remaining:
|
||||
raise UserInputError("All provided users are already blacklisted!")
|
||||
await self.cog.blacklist_users(interaction.user.id, list(remaining), reason)
|
||||
embed = discord.Embed(
|
||||
title="Users Blacklisted",
|
||||
description=(
|
||||
"You have blacklisted the following users:\n"
|
||||
+ (', '.join(f"`{uid}`" for uid in remaining))
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
if self.message is not None:
|
||||
await self.set_user_mode()
|
||||
|
||||
async def do_rm_users(self, interaction: discord.Interaction, userids: list[int]):
|
||||
remaining = self.cog.user_blacklist.intersection(userids)
|
||||
if not remaining:
|
||||
raise UserInputError("None of these users are blacklisted")
|
||||
await self.cog.unblacklist_users(interaction.user.id, list(remaining))
|
||||
embed = discord.Embed(
|
||||
title="Users removed from Blacklist",
|
||||
description=(
|
||||
"You have removed the following users from the blacklist:\n"
|
||||
+ (', '.join(f"`{uid}`" for uid in remaining))
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
if self.message is not None:
|
||||
await self.set_user_mode()
|
||||
|
||||
async def spawn_add_guilds(self, interaction: discord.Interaction,
|
||||
guildids: Optional[str] = None, reason: Optional[str] = None):
|
||||
"""Spawn the add_guilds modal, optionally with fields pre-filled."""
|
||||
modal = BlacklistInput(title="Blacklist guilds")
|
||||
modal.targets.default = guildids
|
||||
modal.reason.default = reason
|
||||
|
||||
@modal.submit_callback()
|
||||
async def add_guilds_submit(interaction):
|
||||
await self.parse_add_guilds(interaction, modal.targets.value, modal.reason.value)
|
||||
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def parse_add_guilds(self, interaction, guildidstr: str, reason: str):
|
||||
"""
|
||||
Parse provided guildid string and reason, and pass onto do_add_guilds.
|
||||
If they are invalid, instead raise a UserInputError.
|
||||
"""
|
||||
try:
|
||||
guildids = parse_ids(guildidstr)
|
||||
except UserInputError as ex:
|
||||
raise UserInputError("Could not extract a guild id from `$item`", info=ex.info) from None
|
||||
|
||||
await self.do_add_guilds(interaction, guildids, reason)
|
||||
|
||||
async def do_add_guilds(self, interaction: discord.Interaction, guildids: list[int], reason: str):
|
||||
"""
|
||||
Actually blacklist the given guilds and send an ack.
|
||||
To be run after initial argument validation.
|
||||
Updates the UI, or posts one if it doesn't exist.
|
||||
"""
|
||||
remaining = set(guildids).difference(self.cog.guild_blacklist)
|
||||
if not remaining:
|
||||
raise UserInputError("All provided guilds are already blacklisted!")
|
||||
await self.cog.blacklist_guilds(interaction.user.id, list(remaining), reason)
|
||||
embed = discord.Embed(
|
||||
title="Guilds Blacklisted",
|
||||
description=(
|
||||
"You have blacklisted the following guilds:\n"
|
||||
+ (', '.join(f"`{gid}`" for gid in remaining))
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
if self.message is not None:
|
||||
await self.set_guild_mode()
|
||||
|
||||
async def do_rm_guilds(self, interaction: discord.Interaction, guildids: list[int]):
|
||||
remaining = self.cog.guild_blacklist.intersection(guildids)
|
||||
if not remaining:
|
||||
raise UserInputError("None of these guilds are blacklisted")
|
||||
await self.cog.unblacklist_guilds(interaction.user.id, list(remaining))
|
||||
embed = discord.Embed(
|
||||
title="Guilds removed from Blacklist",
|
||||
description=(
|
||||
"You have removed the following guilds from the blacklist:\n"
|
||||
+ (', '.join(f"`{gid}`" for gid in remaining))
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
if self.message is not None:
|
||||
await self.set_guild_mode()
|
||||
42
bot/modules/sysadmin/dash.py
Normal file
42
bot/modules/sysadmin/dash.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
The dashboard shows a summary of the various registered global bot settings.
|
||||
"""
|
||||
|
||||
import discord
|
||||
import discord.ext.commands as cmds
|
||||
|
||||
from meta import LionBot, LionCog, LionContext
|
||||
from meta.app import appname
|
||||
from wards import sys_admin
|
||||
|
||||
from settings.groups import SettingGroup
|
||||
|
||||
|
||||
class LeoSettings(LionCog, group_name='leo'):
|
||||
__cog_is_app_commands_group__ = True
|
||||
depends = {'CoreCog'}
|
||||
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
|
||||
self.bot_setting_groups: list[SettingGroup] = []
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name='dashboard',
|
||||
description="Global setting dashboard"
|
||||
)
|
||||
@cmds.check(sys_admin)
|
||||
async def dash_cmd(self, ctx: LionContext):
|
||||
embed = discord.Embed(
|
||||
title="System Admin Dashboard",
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
for group in self.bot_setting_groups:
|
||||
table = await group.make_setting_table(appname)
|
||||
description = group.description.format(ctx=ctx, bot=ctx.bot).strip()
|
||||
embed.add_field(
|
||||
name=group.title.format(ctx=ctx, bot=ctx.bot),
|
||||
value=f"{description}\n{table}"
|
||||
)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
384
bot/modules/sysadmin/exec_cog.py
Normal file
384
bot/modules/sysadmin/exec_cog.py
Normal file
@@ -0,0 +1,384 @@
|
||||
import io
|
||||
import ast
|
||||
import sys
|
||||
import types
|
||||
import asyncio
|
||||
import traceback
|
||||
import builtins
|
||||
import inspect
|
||||
import logging
|
||||
from io import StringIO
|
||||
|
||||
from typing import Callable, Any, Optional
|
||||
|
||||
from enum import Enum
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ui import TextInput, View
|
||||
from discord.ui.button import button
|
||||
import discord.app_commands as appcmd
|
||||
|
||||
from meta.logger import logging_context
|
||||
from meta.app import shard_talk
|
||||
from meta import conf
|
||||
from meta.context import context, ctx_bot
|
||||
from meta.LionContext import LionContext
|
||||
from meta.LionCog import LionCog
|
||||
from meta.LionBot import LionBot
|
||||
|
||||
from utils.ui import FastModal, input
|
||||
|
||||
from wards import sys_admin
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecModal(FastModal, title="Execute"):
|
||||
code: TextInput = TextInput(
|
||||
label="Code to execute",
|
||||
style=discord.TextStyle.long,
|
||||
required=True
|
||||
)
|
||||
|
||||
|
||||
class ExecStyle(Enum):
|
||||
EXEC = 'exec'
|
||||
EVAL = 'eval'
|
||||
|
||||
|
||||
class ExecUI(View):
|
||||
def __init__(self, ctx, code=None, style=ExecStyle.EXEC, ephemeral=True) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.ctx: LionContext = ctx
|
||||
self.interaction: Optional[discord.Interaction] = ctx.interaction
|
||||
self.code: Optional[str] = code
|
||||
self.style: ExecStyle = style
|
||||
self.ephemeral: bool = ephemeral
|
||||
|
||||
self._modal: Optional[ExecModal] = None
|
||||
self._msg: Optional[discord.Message] = None
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction):
|
||||
"""Only allow the original author to use this View"""
|
||||
if interaction.user.id != self.ctx.author.id:
|
||||
await interaction.response.send_message(
|
||||
"You cannot use this interface!",
|
||||
ephemeral=True
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def run(self):
|
||||
if self.code is None:
|
||||
if (interaction := self.interaction) is not None:
|
||||
self.interaction = None
|
||||
await interaction.response.send_modal(self.get_modal())
|
||||
await self.wait()
|
||||
else:
|
||||
# Complain
|
||||
# TODO: error_reply
|
||||
await self.ctx.reply("Pls give code.")
|
||||
else:
|
||||
await self.interaction.response.defer(thinking=True, ephemeral=self.ephemeral)
|
||||
await self.compile()
|
||||
await self.wait()
|
||||
|
||||
@button(label="Recompile")
|
||||
async def recompile_button(self, interaction, butt):
|
||||
# Interaction response with modal
|
||||
await interaction.response.send_modal(self.get_modal())
|
||||
|
||||
@button(label="Show Source")
|
||||
async def source_button(self, interaction, butt):
|
||||
if len(self.code) > 1900:
|
||||
# Send as file
|
||||
with StringIO(self.code) as fp:
|
||||
fp.seek(0)
|
||||
file = discord.File(fp, filename="source.py")
|
||||
await interaction.response.send_message(file=file, ephemeral=True)
|
||||
else:
|
||||
# Send as message
|
||||
await interaction.response.send_message(
|
||||
content=f"```py\n{self.code}```",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
def create_modal(self) -> ExecModal:
|
||||
modal = ExecModal()
|
||||
|
||||
@modal.submit_callback()
|
||||
async def exec_submit(interaction: discord.Interaction):
|
||||
if self.interaction is None:
|
||||
self.interaction = interaction
|
||||
await interaction.response.defer(thinking=True)
|
||||
else:
|
||||
await interaction.response.defer()
|
||||
|
||||
# Set code
|
||||
self.code = modal.code.value
|
||||
|
||||
# Call compile
|
||||
await self.compile()
|
||||
|
||||
return modal
|
||||
|
||||
def get_modal(self):
|
||||
if self._modal is None:
|
||||
# Create modal
|
||||
self._modal = self.create_modal()
|
||||
|
||||
self._modal.code.default = self.code
|
||||
return self._modal
|
||||
|
||||
async def compile(self):
|
||||
# Call _async
|
||||
result = await _async(self.code, style=self.style.value)
|
||||
|
||||
# Display output
|
||||
await self.show_output(result)
|
||||
|
||||
async def show_output(self, output):
|
||||
# Format output
|
||||
# If output message exists and not ephemeral, edit
|
||||
# Otherwise, send message, add buttons
|
||||
if len(output) > 1900:
|
||||
# Send as file
|
||||
with StringIO(output) as fp:
|
||||
fp.seek(0)
|
||||
args = {
|
||||
'content': None,
|
||||
'attachments': [discord.File(fp, filename="output.md")]
|
||||
}
|
||||
else:
|
||||
args = {
|
||||
'content': f"```md\n{output}```",
|
||||
'attachments': []
|
||||
}
|
||||
|
||||
if self._msg is None:
|
||||
if self.interaction is not None:
|
||||
msg = await self.interaction.edit_original_response(**args, view=self)
|
||||
else:
|
||||
# Send new message
|
||||
if args['content'] is None:
|
||||
args['file'] = args.pop('attachments')[0]
|
||||
msg = await self.ctx.reply(**args, ephemeral=self.ephemeral, view=self)
|
||||
|
||||
if not self.ephemeral:
|
||||
self._msg = msg
|
||||
else:
|
||||
if self.interaction is not None:
|
||||
await self.interaction.edit_original_response(**args, view=self)
|
||||
else:
|
||||
# Edit message
|
||||
await self._msg.edit(**args)
|
||||
|
||||
|
||||
def mk_print(fp: io.StringIO) -> Callable[..., None]:
|
||||
def _print(*args, file: Any = fp, **kwargs):
|
||||
return print(*args, file=file, **kwargs)
|
||||
return _print
|
||||
|
||||
|
||||
async def _async(to_eval: str, style='exec'):
|
||||
with logging_context(action="Code Exec"):
|
||||
newline = '\n' * ('\n' in to_eval)
|
||||
logger.info(
|
||||
f"Exec code with {style}: {newline}{to_eval}"
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
_print = mk_print(output)
|
||||
|
||||
scope: dict[str, Any] = dict(sys.modules)
|
||||
scope['__builtins__'] = builtins
|
||||
scope.update(builtins.__dict__)
|
||||
scope['ctx'] = ctx = context.get()
|
||||
scope['bot'] = ctx_bot.get()
|
||||
scope['print'] = _print # type: ignore
|
||||
|
||||
try:
|
||||
if ctx and ctx.message:
|
||||
source_str = f"<msg: {ctx.message.id}>"
|
||||
elif ctx and ctx.interaction:
|
||||
source_str = f"<iid: {ctx.interaction.id}>"
|
||||
else:
|
||||
source_str = "Unknown async"
|
||||
|
||||
code = compile(
|
||||
to_eval,
|
||||
source_str,
|
||||
style,
|
||||
ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
|
||||
)
|
||||
func = types.FunctionType(code, scope)
|
||||
|
||||
ret = func()
|
||||
if inspect.iscoroutine(ret):
|
||||
ret = await ret
|
||||
if ret is not None:
|
||||
_print(repr(ret))
|
||||
except Exception:
|
||||
_, exc, tb = sys.exc_info()
|
||||
_print("".join(traceback.format_tb(tb)))
|
||||
_print(f"{type(exc).__name__}: {exc}")
|
||||
|
||||
result = output.getvalue().strip()
|
||||
newline = '\n' * ('\n' in result)
|
||||
logger.info(
|
||||
f"Exec complete, output: {newline}{result}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class Exec(LionCog):
|
||||
guild_ids = conf.bot.getintlist('admin_guilds')
|
||||
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
|
||||
self.talk_async = shard_talk.register_route('exec')(_async)
|
||||
|
||||
async def cog_check(self, ctx: LionContext) -> bool: # type: ignore
|
||||
return await sys_admin(ctx)
|
||||
|
||||
@commands.hybrid_command(
|
||||
name='async',
|
||||
description="Execute arbitrary code with Exec"
|
||||
)
|
||||
@appcmd.describe(
|
||||
string="Code to execute."
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def async_cmd(self, ctx: LionContext, *, string: Optional[str] = None):
|
||||
await ExecUI(ctx, string, ExecStyle.EXEC).run()
|
||||
|
||||
@commands.hybrid_command(
|
||||
name='eval',
|
||||
description='Execute arbitrary code with Eval'
|
||||
)
|
||||
@appcmd.describe(
|
||||
string="Code to evaluate."
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def eval_cmd(self, ctx: LionContext, *, string: str):
|
||||
await ExecUI(ctx, string, ExecStyle.EVAL).run()
|
||||
|
||||
@commands.hybrid_command(
|
||||
name='asyncall',
|
||||
description="Execute arbitrary code on all shards."
|
||||
)
|
||||
@appcmd.describe(
|
||||
string="Cross-shard code to execute. Cannot reference ctx!",
|
||||
target="Target shard app name, see autocomplete for options."
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def asyncall_cmd(self, ctx: LionContext, string: Optional[str] = None, target: Optional[str] = None):
|
||||
if string is None and ctx.interaction:
|
||||
try:
|
||||
ctx.interaction, string = await input(
|
||||
ctx.interaction, "Cross-shard execute", "Code to execute?",
|
||||
style=discord.TextStyle.long
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
if ctx.interaction:
|
||||
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
|
||||
if target is not None:
|
||||
if target not in shard_talk.peers:
|
||||
embed = discord.Embed(description=f"Unknown peer {target}", colour=discord.Colour.red())
|
||||
if ctx.interaction:
|
||||
await ctx.interaction.edit_original_response(embed=embed)
|
||||
else:
|
||||
await ctx.reply(embed=embed)
|
||||
return
|
||||
else:
|
||||
result = await self.talk_async(string).send(target)
|
||||
results = {target: result}
|
||||
else:
|
||||
results = await self.talk_async(string).broadcast(except_self=False)
|
||||
|
||||
blocks = [f"# {appid}\n{result}" for appid, result in results.items()]
|
||||
output = "\n\n".join(blocks)
|
||||
if len(output) > 1900:
|
||||
# Send as file
|
||||
with StringIO(output) as fp:
|
||||
fp.seek(0)
|
||||
file = discord.File(fp, filename="output.md") # type: ignore
|
||||
await ctx.reply(file=file)
|
||||
else:
|
||||
# Send as message
|
||||
await ctx.reply(f"```md\n{output}```", ephemeral=True)
|
||||
|
||||
@asyncall_cmd.autocomplete('target')
|
||||
async def asyncall_target_acmpl(self, interaction: discord.Interaction, partial: str):
|
||||
appids = set(shard_talk.peers.keys())
|
||||
results = [
|
||||
appcmd.Choice(name=appid, value=appid)
|
||||
for appid in appids
|
||||
if partial.lower() in appid.lower()
|
||||
]
|
||||
if not results:
|
||||
results = [
|
||||
appcmd.Choice(name=f"No peers found matching {partial}", value="None")
|
||||
]
|
||||
return results
|
||||
|
||||
@commands.hybrid_command(
|
||||
name='reload',
|
||||
description="Reload a given LionBot extension. Launches an ExecUI."
|
||||
)
|
||||
@appcmd.describe(
|
||||
extension="Name of the extesion to reload. See autocomplete for options."
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def reload_cmd(self, ctx: LionContext, extension: str):
|
||||
"""
|
||||
This is essentially just a friendly wrapper to reload an extension.
|
||||
It is equivalent to running "await bot.reload_extension(extension)" in eval,
|
||||
with a slightly nicer interface through the autocomplete and error handling.
|
||||
"""
|
||||
if extension not in self.bot.extensions:
|
||||
embed = discord.Embed(description=f"Unknown extension {extension}", colour=discord.Colour.red())
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Uses an ExecUI to simplify error handling and re-execution
|
||||
string = f"await bot.reload_extension('{extension}')"
|
||||
await ExecUI(ctx, string, ExecStyle.EVAL).run()
|
||||
|
||||
@reload_cmd.autocomplete('extension')
|
||||
async def reload_extension_acmpl(self, interaction: discord.Interaction, partial: str):
|
||||
keys = set(self.bot.extensions.keys())
|
||||
results = [
|
||||
appcmd.Choice(name=key, value=key)
|
||||
for key in keys
|
||||
if partial.lower() in key.lower()
|
||||
]
|
||||
if not results:
|
||||
results = [
|
||||
appcmd.Choice(name=f"No extensions found matching {partial}", value="None")
|
||||
]
|
||||
return results
|
||||
|
||||
@commands.hybrid_command(
|
||||
name='shutdown',
|
||||
description="Shutdown (or restart) the client."
|
||||
)
|
||||
@appcmd.guilds(*guild_ids)
|
||||
async def shutdown_cmd(self, ctx: LionContext):
|
||||
"""
|
||||
Shutdown the client.
|
||||
Maybe do something friendly here?
|
||||
"""
|
||||
logger.info("Shutting down on admin request.")
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
description=f"Understood {ctx.author.mention}, cleaning up and shutting down!",
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
)
|
||||
await self.bot.close()
|
||||
89
bot/modules/sysadmin/guild_log.py
Normal file
89
bot/modules/sysadmin/guild_log.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import datetime
|
||||
|
||||
import discord
|
||||
from discord import Webhook
|
||||
|
||||
from meta.LionCog import LionCog
|
||||
from meta.LionBot import LionBot
|
||||
from meta.logger import log_wrap
|
||||
|
||||
|
||||
class GuildLog(LionCog):
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
|
||||
@LionCog.listener('on_guild_remove')
|
||||
@log_wrap(action="Log Guild Leave")
|
||||
async def log_left_guild(self, guild: discord.Guild):
|
||||
# Build embed
|
||||
embed = discord.Embed(title="`{0.name} (ID: {0.id})`".format(guild),
|
||||
colour=discord.Colour.red(),
|
||||
timestamp=datetime.datetime.utcnow())
|
||||
embed.set_author(name="Left guild!")
|
||||
|
||||
# Add more specific information about the guild
|
||||
embed.add_field(name="Owner", value="{0.name} (ID: {0.id})".format(guild.owner), inline=False)
|
||||
embed.add_field(name="Members (cached)", value="{}".format(len(guild.members)), inline=False)
|
||||
embed.add_field(name="Now studying in", value="{} guilds".format(len(self.bot.guilds)), inline=False)
|
||||
|
||||
# Retrieve the guild log channel and log the event
|
||||
log_webhook = self.bot.config.endpoints.get("guild_log")
|
||||
if log_webhook:
|
||||
webhook = Webhook.from_url(log_webhook, session=self.bot.web_client)
|
||||
await webhook.send(embed=embed, username=self.bot.appname)
|
||||
|
||||
@LionCog.listener('on_guild_join')
|
||||
@log_wrap(action="Log Guild Join")
|
||||
async def log_join_guild(self, guild: discord.Guild):
|
||||
owner = guild.owner
|
||||
|
||||
bots = 0
|
||||
known = 0
|
||||
unknown = 0
|
||||
other_members = set(mem.id for mem in self.bot.get_all_members() if mem.guild != guild)
|
||||
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
bots += 1
|
||||
elif member.id in other_members:
|
||||
known += 1
|
||||
else:
|
||||
unknown += 1
|
||||
|
||||
mem1 = "people I know" if known != 1 else "person I know"
|
||||
mem2 = "new friends" if unknown != 1 else "new friend"
|
||||
mem3 = "bots" if bots != 1 else "bot"
|
||||
mem4 = "total members"
|
||||
known = "`{}`".format(known)
|
||||
unknown = "`{}`".format(unknown)
|
||||
bots = "`{}`".format(bots)
|
||||
total = "`{}`".format(guild.member_count)
|
||||
mem_str = "{0:<5}\t{4},\n{1:<5}\t{5},\n{2:<5}\t{6}, and\n{3:<5}\t{7}.".format(
|
||||
known,
|
||||
unknown,
|
||||
bots,
|
||||
total,
|
||||
mem1,
|
||||
mem2,
|
||||
mem3,
|
||||
mem4
|
||||
)
|
||||
created = "<t:{}>".format(int(guild.created_at.timestamp()))
|
||||
|
||||
embed = discord.Embed(
|
||||
title="`{0.name} (ID: {0.id})`".format(guild),
|
||||
colour=discord.Colour.green(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
embed.set_author(name="Joined guild!")
|
||||
|
||||
embed.add_field(name="Owner", value="{0} (ID: {0.id})".format(owner), inline=False)
|
||||
embed.add_field(name="Created at", value=created, inline=False)
|
||||
embed.add_field(name="Members", value=mem_str, inline=False)
|
||||
embed.add_field(name="Now studying in", value="{} guilds".format(len(self.bot.guilds)), inline=False)
|
||||
|
||||
# Retrieve the guild log channel and log the event
|
||||
log_webhook = self.bot.config.endpoints.get("guild_log")
|
||||
if log_webhook:
|
||||
webhook = Webhook.from_url(log_webhook, session=self.bot.web_client)
|
||||
await webhook.send(embed=embed, username=self.bot.appname)
|
||||
68
bot/modules/sysadmin/leo_group.py
Normal file
68
bot/modules/sysadmin/leo_group.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from discord.app_commands import Group, Command
|
||||
from discord.ext.commands import HybridCommand
|
||||
|
||||
from meta import LionCog
|
||||
|
||||
|
||||
class LeoGroup(Group, name='leo'):
|
||||
"""
|
||||
Base command group for all Leo system admin commands.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
"""
|
||||
TODO:
|
||||
This will take some work to get working.
|
||||
We want to be able to specify a command in a cog
|
||||
as a subcommand of a group command in a different cog,
|
||||
or even a different extension.
|
||||
Unfortunately, this really messes with the hotloading and unloading,
|
||||
and may require overriding LionCog.__new__.
|
||||
|
||||
We also have to answer some implementation decisions,
|
||||
such as what happens when the child command cog gets unloaded/reloaded?
|
||||
What happens when the group command gets unloaded/reloaded?
|
||||
|
||||
Well, if the child cog gets unloaded, it makes sense to detach the commands.
|
||||
The commands should keep their binding to the defining cog,
|
||||
the parent command is mainly relevant for the CommandTree, which we have control of anyway..
|
||||
|
||||
If the parent cog gets unloaded, it makes sense to unload all the subcommands, if possible.
|
||||
|
||||
Now technically, it shouldn't _matter_ where the child command is defined.
|
||||
The Tree is in charge (or should be) of arranging parent commands and subcommands.
|
||||
The Group class should just specify some optional extra properties or wrappers
|
||||
to apply to the subcommands.
|
||||
So perhaps we can just extend Hybrid command to actually pass in a parent...
|
||||
Or specify a _string_ as the parent, which gets mapped with a group class
|
||||
if it exists.. but it doesn't need to exist.
|
||||
"""
|
||||
|
||||
|
||||
class LeoCog(LionCog):
|
||||
"""
|
||||
Abstract container cog acting as a manager for the LeoGroup above.
|
||||
"""
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.commands = []
|
||||
self.group = LeoGroup()
|
||||
|
||||
def attach(self, *commands):
|
||||
"""
|
||||
Attach the given commands to the LeoGroup group.
|
||||
"""
|
||||
for command in commands:
|
||||
if isinstance(command, Command):
|
||||
# Classic app command, attach as-is
|
||||
cmd = command
|
||||
elif isinstance(command, HybridCommand):
|
||||
cmd = command.app_command
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Command must by 'app_commands.Command' or 'commands.HybridCommand' not {cmd.__class_}"
|
||||
)
|
||||
self.group.add_command(cmd)
|
||||
|
||||
self.commands.extend(commands)
|
||||
375
bot/modules/sysadmin/presence.py
Normal file
375
bot/modules/sysadmin/presence.py
Normal file
@@ -0,0 +1,375 @@
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
import logging
|
||||
from string import Template
|
||||
|
||||
import discord
|
||||
from discord.ext import commands as cmds
|
||||
import discord.app_commands as appcmds
|
||||
from discord.app_commands.transformers import AppCommandOptionType
|
||||
|
||||
from meta import LionCog, LionBot, LionContext
|
||||
from meta.logger import log_wrap
|
||||
from meta.app import shard_talk, appname
|
||||
from utils.ui import ChoicedEnum, Transformed
|
||||
from utils.lib import tabulate
|
||||
|
||||
from data import RowModel, Registry, RegisterEnum
|
||||
from data.columns import String, Column
|
||||
|
||||
from settings.data import ModelData
|
||||
from settings.setting_types import EnumSetting, StringSetting
|
||||
from settings.groups import SettingGroup
|
||||
|
||||
from wards import sys_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppActivityType(ChoicedEnum):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TYPE ActivityType AS ENUM(
|
||||
'PLAYING',
|
||||
'WATCHING',
|
||||
'LISTENING',
|
||||
'STREAMING'
|
||||
);
|
||||
"""
|
||||
playing = ('PLAYING', 'Playing', discord.ActivityType.playing)
|
||||
watching = ('WATCHING', 'Watching', discord.ActivityType.watching)
|
||||
listening = ('LISTENING', 'Listening', discord.ActivityType.listening)
|
||||
streaming = ('STREAMING', 'Streaming', discord.ActivityType.streaming)
|
||||
|
||||
@property
|
||||
def choice_name(self):
|
||||
return self.value[1]
|
||||
|
||||
@property
|
||||
def choice_value(self):
|
||||
return self.value[1]
|
||||
|
||||
|
||||
class AppStatus(ChoicedEnum):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TYPE OnlineStatus AS ENUM(
|
||||
'ONLINE',
|
||||
'IDLE',
|
||||
'DND',
|
||||
'OFFLINE'
|
||||
);
|
||||
"""
|
||||
online = ('ONLINE', 'Online', discord.Status.online)
|
||||
idle = ('IDLE', 'Idle', discord.Status.idle)
|
||||
dnd = ('DND', 'Do Not Disturb', discord.Status.dnd)
|
||||
offline = ('OFFLINE', 'Offline/Invisible', discord.Status.offline)
|
||||
|
||||
@property
|
||||
def choice_name(self):
|
||||
return self.value[1]
|
||||
|
||||
@property
|
||||
def choice_value(self):
|
||||
return self.value[1]
|
||||
|
||||
|
||||
class PresenceData(Registry, name='presence'):
|
||||
class AppPresence(RowModel):
|
||||
"""
|
||||
Schema
|
||||
------
|
||||
CREATE TABLE bot_config_presence(
|
||||
appname TEXT PRIMARY KEY REFERENCES bot_config(appname) ON DELETE CASCADE,
|
||||
online_status OnlineStatus,
|
||||
activity_type ActivityType,
|
||||
activity_name Text
|
||||
);
|
||||
"""
|
||||
_tablename_ = 'bot_config_presence'
|
||||
_cache_ = {}
|
||||
|
||||
appname = String(primary=True)
|
||||
online_status: Column[AppStatus] = Column()
|
||||
activity_type: Column[AppActivityType] = Column()
|
||||
activity_name = String()
|
||||
|
||||
AppActivityType = RegisterEnum(AppActivityType, name="ActivityType")
|
||||
AppStatus = RegisterEnum(AppStatus, name='OnlineStatus')
|
||||
|
||||
|
||||
class PresenceSettings(SettingGroup):
|
||||
"""
|
||||
Control the bot status and activity.
|
||||
"""
|
||||
_title = "Presence Settings ({bot.core.cmd_name_cache[presence].mention})"
|
||||
|
||||
class PresenceStatus(ModelData, EnumSetting[str, AppStatus]):
|
||||
display_name = 'online_status'
|
||||
desc = "Bot status indicator"
|
||||
long_desc = "Whether the bot account displays as online, idle, dnd, or offline."
|
||||
accepts = "One of 'online', 'idle', 'dnd', or 'offline'."
|
||||
|
||||
_model = PresenceData.AppPresence
|
||||
_column = PresenceData.AppPresence.online_status.name
|
||||
_create_row = True
|
||||
|
||||
_enum = AppStatus
|
||||
_outputs = {item: item.value[1] for item in _enum}
|
||||
_inputs = {item.name: item for item in _enum}
|
||||
_default = AppStatus.online
|
||||
|
||||
class PresenceType(ModelData, EnumSetting[str, AppActivityType]):
|
||||
display_name = 'activity_type'
|
||||
desc = "Type of presence activity"
|
||||
long_desc = "Whether the bot activity is shown as 'Listening', 'Playing', or 'Watching'."
|
||||
accepts = "One of 'listening', 'playing', 'watching', or 'streaming'."
|
||||
|
||||
_model = PresenceData.AppPresence
|
||||
_column = PresenceData.AppPresence.activity_type.name
|
||||
_create_row = True
|
||||
|
||||
_enum = AppActivityType
|
||||
_outputs = {item: item.value[1] for item in _enum}
|
||||
_inputs = {item.name: item for item in _enum}
|
||||
_default = AppActivityType.watching
|
||||
|
||||
class PresenceName(ModelData, StringSetting[str]):
|
||||
display_name = 'activity_name'
|
||||
desc = "Name of the presence activity"
|
||||
long_desc = "Presence activity name."
|
||||
accepts = "Any string."
|
||||
|
||||
_model = PresenceData.AppPresence
|
||||
_column = PresenceData.AppPresence.activity_name.name
|
||||
_create_row = True
|
||||
_default = "$in_vc students in $voice_channels study rooms!"
|
||||
|
||||
|
||||
class PresenceCtrl(LionCog):
|
||||
depends = {'CoreCog', 'LeoSettings'}
|
||||
|
||||
# Only update every 60 seconds at most
|
||||
ratelimit = 60
|
||||
|
||||
# Update at least every 300 seconds regardless of events
|
||||
interval = 300
|
||||
|
||||
# Possible substitution keys, and the events that listen to them
|
||||
keys = {
|
||||
'$in_vc': {'on_voice_state_update'},
|
||||
'$voice_channels': {'on_channel_add', 'on_channel_remove'},
|
||||
'$shard_members': {'on_member_join', 'on_member_leave'},
|
||||
'$shard_guilds': {'on_guild_join', 'on_guild_leave'}
|
||||
}
|
||||
|
||||
default_format = "$in_vc students in $voice_channels study rooms!"
|
||||
default_activity = discord.ActivityType.watching
|
||||
default_status = discord.Status.online
|
||||
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data = bot.db.load_registry(PresenceData())
|
||||
self.settings = PresenceSettings()
|
||||
|
||||
self.activity_type: discord.ActivityType = self.default_activity
|
||||
self.activity_format: str = self.default_format
|
||||
self.status: discord.Status = self.default_status
|
||||
|
||||
self._listening: set = set()
|
||||
self._tick = asyncio.Event()
|
||||
self._loop_task: Optional[asyncio.Task] = None
|
||||
|
||||
self.talk_reload_presence = shard_talk.register_route("reload presence")(self.reload_presence)
|
||||
|
||||
async def cog_load(self):
|
||||
await self.data.init()
|
||||
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
|
||||
leo_setting_cog.bot_setting_groups.append(self.settings)
|
||||
|
||||
await self.reload_presence()
|
||||
self.update_listeners()
|
||||
self._loop_task = asyncio.create_task(self.presence_loop())
|
||||
await self.tick()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""
|
||||
De-register the event listeners, and cancel the presence update loop.
|
||||
"""
|
||||
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
|
||||
leo_setting_cog.bot_setting_groups.remove(self.settings)
|
||||
|
||||
if self._loop_task is not None and not self._loop_task.done():
|
||||
self._loop_task.cancel("Unloading")
|
||||
|
||||
for event in self._listening:
|
||||
self.bot.remove_listener(self.tick, event)
|
||||
self._listening.discard(event)
|
||||
|
||||
def update_listeners(self):
|
||||
# Build the list of events that should trigger status updates
|
||||
# Un-register any current listeners we don't need
|
||||
# Re-register any new listeners we need
|
||||
new_listeners = set()
|
||||
for key, events in self.keys.items():
|
||||
if key in self.activity_format:
|
||||
new_listeners.update(events)
|
||||
to_remove = self._listening.difference(new_listeners)
|
||||
to_add = new_listeners.difference(self._listening)
|
||||
|
||||
for event in to_remove:
|
||||
self.bot.remove_listener(self.tick, event)
|
||||
for event in to_add:
|
||||
self.bot.add_listener(self.tick, event)
|
||||
|
||||
self._listening = new_listeners
|
||||
|
||||
async def reload_presence(self) -> None:
|
||||
# Reload the presence information from the appconfig table
|
||||
# TODO: When botconfig is done, these should load from settings, instead of directly from data
|
||||
self.data.AppPresence._cache_.pop(appname, None)
|
||||
self.activity_type = (await self.settings.PresenceType.get(appname)).value.value[2]
|
||||
self.activity_format = (await self.settings.PresenceName.get(appname)).value
|
||||
self.status = (await self.settings.PresenceStatus.get(appname)).value.value[2]
|
||||
|
||||
async def set_presence(self, activity: Optional[discord.BaseActivity], status: Optional[discord.Status]):
|
||||
"""
|
||||
Globally change the client presence and save the new presence information.
|
||||
"""
|
||||
# TODO: Waiting on botconfig settings
|
||||
self.activity_type = activity.type if activity else None
|
||||
self.activity_name = activity.name if activity else None
|
||||
self.status = status or self.status
|
||||
await self.talk_reload_presence().broadcast(except_self=False)
|
||||
|
||||
async def format_activity(self, form: str) -> str:
|
||||
"""
|
||||
Format the given string.
|
||||
"""
|
||||
subs = {
|
||||
'shard_members': sum(1 for _ in self.bot.get_all_members()),
|
||||
'shard_guilds': sum(1 for _ in self.bot.guilds)
|
||||
}
|
||||
if '$in_vc' in form:
|
||||
# TODO: Waiting on study module data
|
||||
subs['in_vc'] = sum(1 for m in self.bot.get_all_members() if m.voice and m.voice.channel)
|
||||
if '$voice_channels' in form:
|
||||
# TODO: Waiting on study module data
|
||||
subs['voice_channels'] = sum(1 for c in self.bot.get_all_channels() if c.type == discord.ChannelType.voice)
|
||||
|
||||
return Template(form).safe_substitute(subs)
|
||||
|
||||
async def tick(self, *args, **kwargs):
|
||||
"""
|
||||
Request a presence update when next possible.
|
||||
Arbitrary arguments allow this to be used as a generic event listener.
|
||||
"""
|
||||
self._tick.set()
|
||||
|
||||
@log_wrap(action="Presence Update")
|
||||
async def _do_presence_update(self):
|
||||
try:
|
||||
activity_name = await self.format_activity(self.activity_format)
|
||||
await self.bot.change_presence(
|
||||
activity=discord.Activity(
|
||||
type=self.activity_type,
|
||||
name=activity_name
|
||||
),
|
||||
status=self.status
|
||||
)
|
||||
logger.debug(
|
||||
"Set status to '%s' with activity '%s' \"%s\"",
|
||||
str(self.status), str(self.activity_type), str(activity_name)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unhandled exception occurred while updating client presence. Ignoring."
|
||||
)
|
||||
|
||||
@log_wrap(stack=["Presence", "Loop"])
|
||||
async def presence_loop(self):
|
||||
"""
|
||||
Request a client presence update when possible.
|
||||
"""
|
||||
await self.bot.wait_until_ready()
|
||||
logger.debug("Launching presence update loop.")
|
||||
try:
|
||||
while True:
|
||||
# Wait for the wakeup event
|
||||
try:
|
||||
await asyncio.wait_for(self._tick.wait(), timeout=self.interval)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
# Clear the wakeup event
|
||||
self._tick.clear()
|
||||
|
||||
# Run the presence update
|
||||
await self._do_presence_update()
|
||||
|
||||
# Wait for the delay
|
||||
await asyncio.sleep(self.ratelimit)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Closing client presence update loop.")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unhandled exception occurred running client presence update loop. Closing loop."
|
||||
)
|
||||
|
||||
@cmds.hybrid_command(
|
||||
name="presence",
|
||||
description="Globally set the bot status and activity."
|
||||
)
|
||||
@cmds.check(sys_admin)
|
||||
@appcmds.describe(
|
||||
status="Online status (online | idle | dnd | offline)",
|
||||
type="Activity type (watching | listening | playing | streaming)",
|
||||
string="Activity name, supports substitutions $in_vc, $voice_channels, $shard_guilds, $shard_members"
|
||||
)
|
||||
async def presence_cmd(
|
||||
self,
|
||||
ctx: LionContext,
|
||||
status: Optional[Transformed[AppStatus, AppCommandOptionType.string]] = None,
|
||||
type: Optional[Transformed[AppActivityType, AppCommandOptionType.string]] = None,
|
||||
string: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Modify the client online status and activity.
|
||||
|
||||
Discord makes no guarantees as to which combination of activity type and arguments actually work.
|
||||
"""
|
||||
colours = {
|
||||
discord.Status.online: discord.Colour.green(),
|
||||
discord.Status.idle: discord.Colour.orange(),
|
||||
discord.Status.dnd: discord.Colour.red(),
|
||||
discord.Status.offline: discord.Colour.light_grey()
|
||||
}
|
||||
|
||||
if any((status, type, string)):
|
||||
# TODO: Batch?
|
||||
if status is not None:
|
||||
await self.settings.PresenceStatus(appname, status).write()
|
||||
if type is not None:
|
||||
await self.settings.PresenceType(appname, type).write()
|
||||
if string is not None:
|
||||
await self.settings.PresenceName(appname, string).write()
|
||||
|
||||
await self.talk_reload_presence().broadcast(except_self=False)
|
||||
await self._do_presence_update()
|
||||
|
||||
current_name = await self.format_activity(self.activity_format)
|
||||
table = '\n'.join(
|
||||
tabulate(
|
||||
('Status', self.status.name),
|
||||
('Activity', f"{self.activity_type.name} {current_name}"),
|
||||
)
|
||||
)
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
title="Current Presence",
|
||||
description=table,
|
||||
colour=colours[self.status]
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user