Merge branch 'rewrite' into pillow

This commit is contained in:
2023-09-24 10:58:59 +03:00
178 changed files with 29691 additions and 2267 deletions

View File

@@ -22,7 +22,7 @@ async def shard_snapshot():
snap = ShardSnapshot(
guild_count=len(bot.guilds),
voice_count=sum(len(channel.members) for guild in bot.guilds for channel in guild.voice_channels),
member_count=sum(len(guild.members) for guild in bot.guilds),
member_count=sum(guild.member_count for guild in bot.guilds),
user_count=len(set(m.id for guild in bot.guilds for m in guild.members))
)
return snap

View File

@@ -230,14 +230,15 @@ class BabelCog(LionCog):
supported = self.bot.translator.supported_locales
formatted = []
for locale in supported:
name = locale_names.get(locale.replace('_', '-'), None)
if name:
localestr = f"{locale} ({t(name)})"
names = locale_names.get(locale.replace('_', '-'), None)
if names:
local_name, native_name = names
localestr = f"{native_name} ({t(local_name)})"
else:
localestr = locale
formatted.append((locale, localestr))
matching = {item for item in formatted if partial in item[1]}
matching = {item for item in formatted if partial in item[1] or partial in item[0]}
if matching:
choices = [
appcmds.Choice(name=localestr, value=locale)

View File

@@ -38,37 +38,44 @@ class LocaleMap(Enum):
hebrew = 'he-IL'
# Original Discord names
locale_names = {
'en-US': _p('localenames|locale:en-US', "American English"),
'en-GB': _p('localenames|locale:en-GB', "British English"),
'bg': _p('localenames|locale:bg', "Bulgarian"),
'zh-CN': _p('localenames|locale:zh-CN', "Chinese"),
'zh-TW': _p('localenames|locale:zh-TW', "Taiwan Chinese"),
'hr': _p('localenames|locale:hr', "Croatian"),
'cs': _p('localenames|locale:cs', "Czech"),
'da': _p('localenames|locale:da', "Danish"),
'nl': _p('localenames|locale:nl', "Dutch"),
'fi': _p('localenames|locale:fi', "Finnish"),
'fr': _p('localenames|locale:fr', "French"),
'de': _p('localenames|locale:de', "German"),
'el': _p('localenames|locale:el', "Greek"),
'hi': _p('localenames|locale:hi', "Hindi"),
'hu': _p('localenames|locale:hu', "Hungarian"),
'it': _p('localenames|locale:it', "Italian"),
'ja': _p('localenames|locale:ja', "Japanese"),
'ko': _p('localenames|locale:ko', "Korean"),
'lt': _p('localenames|locale:lt', "Lithuanian"),
'no': _p('localenames|locale:no', "Norwegian"),
'pl': _p('localenames|locale:pl', "Polish"),
'pt-BR': _p('localenames|locale:pt-BR', "Brazil Portuguese"),
'ro': _p('localenames|locale:ro', "Romanian"),
'ru': _p('localenames|locale:ru', "Russian"),
'es-ES': _p('localenames|locale:es-ES', "Spain Spanish"),
'sv-SE': _p('localenames|locale:sv-SE', "Swedish"),
'th': _p('localenames|locale:th', "Thai"),
'tr': _p('localenames|locale:tr', "Turkish"),
'uk': _p('localenames|locale:uk', "Ukrainian"),
'vi': _p('localenames|locale:vi', "Vietnamese"),
'he': _p('localenames|locale:he', "Hebrew"),
'he-IL': _p('localenames|locale:he_IL', "Hebrew (Israel)"),
'id': (_p('localenames|locale:id', "Indonesian"), "Bahasa Indonesia"),
'da': (_p('localenames|locale:da', "Danish"), "Dansk"),
'de': (_p('localenames|locale:de', "German"), "Deutsch"),
'en-GB': (_p('localenames|locale:en-GB', "English, UK"), "English, UK"),
'en-US': (_p('localenames|locale:en-US', "English, US"), "English, US"),
'es-ES': (_p('localenames|locale:es-ES', "Spanish"), "Español"),
'fr': (_p('localenames|locale:fr', "French"), "Français"),
'hr': (_p('localenames|locale:hr', "Croatian"), "Hrvatski"),
'it': (_p('localenames|locale:it', "Italian"), "Italiano"),
'lt': (_p('localenames|locale:lt', "Lithuanian"), "Lietuviškai"),
'hu': (_p('localenames|locale:hu', "Hungarian"), "Magyar"),
'nl': (_p('localenames|locale:nl', "Dutch"), "Nederlands"),
'no': (_p('localenames|locale:no', "Norwegian"), "Norsk"),
'pl': (_p('localenames|locale:pl', "Polish"), "Polski"),
'pt-BR': (_p('localenames|locale:pt-BR', "Portuguese, Brazilian"), "Português do Brasil"),
'ro': (_p('localenames|locale:ro', "Romanian, Romania"), "Română"),
'fi': (_p('localenames|locale:fi', "Finnish"), "Suomi"),
'sv-SE': (_p('localenames|locale:sv-SE', "Swedish"), "Svenska"),
'vi': (_p('localenames|locale:vi', "Vietnamese"), "Tiếng Việt"),
'tr': (_p('localenames|locale:tr', "Turkish"), "Türkçe"),
'cs': (_p('localenames|locale:cs', "Czech"), "Čeština"),
'el': (_p('localenames|locale:el', "Greek"), "Ελληνικά"),
'bg': (_p('localenames|locale:bg', "Bulgarian"), "български"),
'ru': (_p('localenames|locale:ru', "Russian"), "Pусский"),
'uk': (_p('localenames|locale:uk', "Ukrainian"), "Українська"),
'hi': (_p('localenames|locale:hi', "Hindi"), "हिन्दी"),
'th': (_p('localenames|locale:th', "Thai"), "ไทย"),
'zh-CN': (_p('localenames|locale:zh-CN', "Chinese, China"), "中文"),
'ja': (_p('localenames|locale:ja', "Japanese"), "日本語"),
'zh-TW': (_p('localenames|locale:zh-TW', "Chinese, Taiwan"), "繁體中文"),
'ko': (_p('localenames|locale:ko', "Korean"), "한국어"),
}
# More names for languages not supported by Discord
locale_names |= {
'he': (_p('localenames|locale:he', "Hebrew"), "Hebrew"),
'he-IL': (_p('localenames|locale:he-IL', "Hebrew"), "Hebrew"),
'ceaser': (_p('localenames|locale:test', "Test Language"), "dfbtfs"),
}

View File

@@ -43,9 +43,9 @@ class LocaleSetting(StringSetting):
if data is None:
formatted = t(_p('settype:locale|formatted:unset', "Unset"))
else:
name = locale_names.get(data, None)
if name:
formatted = f"`{data} ({t(name)})`"
if data in locale_names:
local_name, native_name = locale_names[data]
formatted = f"`{native_name} ({t(local_name)})`"
else:
formatted = f"`{data}`"
return formatted

View File

@@ -47,33 +47,43 @@ class LeoBabel(Translator):
Initialise the gettext translators for the supported_locales.
"""
self.read_supported()
missing = []
loaded = []
for locale in self.supported_locales:
for domain in self.supported_domains:
if locale == SOURCE_LOCALE:
continue
try:
translator = gettext.translation(domain, "locales/", languages=[locale])
loaded.append(f"Loaded translator for <locale: {locale}> <domain: {domain}>")
except OSError:
# Presume translation does not exist
logger.warning(f"Could not load translator for supported <locale: {locale}> <domain: {domain}>")
pass
else:
logger.debug(f"Loaded translator for <locale: {locale}> <domain: {domain}>")
self.translators[locale][domain] = translator
missing.append(f"Could not load translator for supported <locale: {locale}> <domain: {domain}>")
translator = null
self.translators[locale][domain] = translator
if missing:
logger.warning('\n'.join(("Missing Translators:", *missing)))
if loaded:
logger.debug('\n'.join(("Loaded Translators:", *loaded)))
async def unload(self):
self.translators.clear()
def get_translator(self, locale, domain):
if locale == SOURCE_LOCALE:
return null
translator = self.translators[locale].get(domain, None)
if translator is None:
logger.warning(
f"Translator missing for requested <locale: {locale}> and <domain: {domain}>. Setting NullTranslator."
)
self.translators[locale][domain] = null
translator = null
elif locale in self.supported_locales and domain in self.supported_domains:
translator = self.translators[locale].get(domain, None)
if translator is None:
# This should never really happen because we already loaded the supported translators
logger.warning(
f"Translator missing for supported <locale: {locale}> "
"and <domain: {domain}>. Setting NullTranslator."
)
translator = self.translators[locale][domain] = null
else:
# Unsupported
translator = null
return translator

View File

@@ -9,6 +9,7 @@ from meta import LionBot, conf, sharding, appname, shard_talk
from meta.app import shardname
from meta.logger import log_context, log_action_stack, setup_main_logger
from meta.context import ctx_bot
from meta.monitor import ComponentMonitor, StatusLevel, ComponentStatus
from data import Database
@@ -21,7 +22,7 @@ for name in conf.config.options('LOGGING_LEVELS', no_defaults=True):
logging.getLogger(name).setLevel(conf.logging_levels[name])
setup_main_logger()
logging_queue = setup_main_logger()
logger = logging.getLogger(__name__)
@@ -29,6 +30,25 @@ logger = logging.getLogger(__name__)
db = Database(conf.data['args'])
async def _data_monitor() -> ComponentStatus:
"""
Component monitor callback for the database.
"""
data = {
'stats': str(db.pool.get_stats())
}
if not db.pool._opened:
level = StatusLevel.WAITING
info = "(WAITING) Database Pool is not opened."
elif db.pool._closed:
level = StatusLevel.ERRORED
info = "(ERROR) Database Pool is closed."
else:
level = StatusLevel.OKAY
info = "(OK) Database Pool statistics: {stats}"
return ComponentStatus(level, info, info, data)
async def main():
log_action_stack.set(("Initialising",))
logger.info("Initialising StudyLion")
@@ -69,9 +89,13 @@ async def main():
shard_count=sharding.shard_count,
help_command=None,
proxy=conf.bot.get('proxy', None),
translator=translator
translator=translator,
chunk_guilds_at_startup=False,
) as lionbot:
ctx_bot.set(lionbot)
lionbot.system_monitor.add_component(
ComponentMonitor('Database', _data_monitor)
)
try:
log_context.set(f"APP: {appname}")
logger.info("StudyLion initialised, starting!", extra={'action': 'Starting'})

View File

@@ -26,6 +26,7 @@ class ConfigCog(LionCog):
@cmds.hybrid_group(
name=_p('group:configure', "configure"),
description=_p('group:configure|desc', "View and adjust my configuration options."),
)
@appcmds.guild_only
@appcmds.default_permissions(manage_guild=True)

View File

@@ -179,9 +179,6 @@ class CoreData(Registry, name="core"):
mod_log_channel = Integer()
alert_channel = Integer()
studyban_role = Integer()
max_study_bans = Integer()
min_workout_length = Integer()
workout_reward = Integer()
@@ -209,6 +206,8 @@ class CoreData(Registry, name="core"):
video_studyban = Bool()
video_grace_period = Integer()
studyban_role = Integer()
greeting_channel = Integer()
greeting_message = String()
returning_message = String()

View File

@@ -85,6 +85,8 @@ class LionMember(Timezoned):
"""
if member.display_name != self.data.display_name:
await self.data.update(display_name=member.display_name)
else:
await self.data.refresh()
async def fetch_member(self) -> Optional[discord.Member]:
"""

View File

@@ -49,16 +49,20 @@ class CoinSetting(IntegerSetting):
if num > cls._max:
t = ctx_translator.get().t
raise UserInputError(t(_p(
'settype:coin|parse|error:too_large',
"Provided number of coins was too high!"
))) from None
raise UserInputError(
t(_p(
'settype:coin|parse|error:too_large',
"You cannot set this to more than {coin}**{max}**!"
)).format(coin=conf.emojis.coin, max=cls._max)
) from None
elif num < cls._min:
t = ctx_translator.get().t
raise UserInputError(t(_p(
'settype:coin|parse|error:too_large',
"Provided number of coins was too low!"
))) from None
raise UserInputError(
t(_p(
'settype:coin|parse|error:too_small',
"You cannot set this to less than {coin}**{min}**!"
)).format(coin=conf.emojis.coin, min=cls._min)
) from None
return num

Submodule src/gui updated: 3952086fb0...ba9ace6ced

View File

@@ -7,10 +7,13 @@ import discord
from discord.utils import MISSING
from discord.ext.commands import Bot, Cog, HybridCommand, HybridCommandError
from discord.ext.commands.errors import CommandInvokeError, CheckFailure
from discord.app_commands.errors import CommandInvokeError as appCommandInvokeError
from discord.app_commands.errors import CommandInvokeError as appCommandInvokeError, TransformerError
from aiohttp import ClientSession
from data import Database
from utils.lib import tabulate
from gui.errors import RenderingException
from babel.translator import ctx_locale
from .config import Conf
from .logger import logging_context, log_context, log_action_stack, log_wrap, set_logging_context
@@ -18,6 +21,7 @@ from .context import context
from .LionContext import LionContext
from .LionTree import LionTree
from .errors import HandledException, SafeCancellation
from .monitor import SystemMonitor, ComponentMonitor, StatusLevel, ComponentStatus
if TYPE_CHECKING:
from core import CoreCog
@@ -45,9 +49,40 @@ class LionBot(Bot):
self.core: Optional['CoreCog'] = None
self.translator = translator
self.system_monitor = SystemMonitor()
self.monitor = ComponentMonitor('LionBot', self._monitor_status)
self.system_monitor.add_component(self.monitor)
self._locks = WeakValueDictionary()
self._running_events = set()
async def _monitor_status(self):
if self.is_closed():
level = StatusLevel.ERRORED
info = "(ERROR) Websocket is closed"
data = {}
elif self.is_ws_ratelimited():
level = StatusLevel.WAITING
info = "(WAITING) Websocket is ratelimited"
data = {}
elif not self.is_ready():
level = StatusLevel.STARTING
info = "(STARTING) Not yet ready"
data = {}
else:
level = StatusLevel.OKAY
info = (
"(OK) "
"Logged in with {guild_count} guilds, "
", websocket latency {latency}, and {events} running events."
)
data = {
'guild_count': len(self.guilds),
'latency': self.latency,
'events': len(self._running_events),
}
return ComponentStatus(level, info, info, data)
async def setup_hook(self) -> None:
log_context.set(f"APP: {self.application_id}")
await self.app_ipc.connect()
@@ -160,6 +195,17 @@ class LionBot(Bot):
raise original
except HandledException:
pass
except TransformerError as e:
msg = str(e)
if msg:
try:
await ctx.error_reply(msg)
except Exception:
pass
logger.debug(
f"Caught a transformer error: {repr(e)}",
extra={'action': 'BotError', 'with_ctx': True}
)
except SafeCancellation:
if original.msg:
try:
@@ -183,7 +229,7 @@ class LionBot(Bot):
extra={'action': 'BotError', 'with_ctx': True}
)
except discord.HTTPException:
logger.warning(
logger.error(
f"Caught an unhandled 'HTTPException' while executing: {cmd_str}",
exc_info=True,
extra={'action': 'BotError', 'with_ctx': True}
@@ -192,23 +238,60 @@ class LionBot(Bot):
pass
except asyncio.TimeoutError:
pass
except Exception:
except RenderingException as e:
logger.info(f"Command failed due to RenderingException: {repr(e)}")
embed = self.tree.rendersplat(e)
try:
await ctx.error_reply(embed=embed)
except discord.HTTPException:
pass
except Exception as e:
logger.exception(
f"Caught an unknown CommandInvokeError while executing: {cmd_str}",
extra={'action': 'BotError', 'with_ctx': True}
)
error_embed = discord.Embed(title="Something went wrong!")
error_embed = discord.Embed(
title="Something went wrong!",
colour=discord.Colour.dark_red()
)
error_embed.description = (
"An unexpected error occurred while processing your command!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
"If the error persists, please contact our support team and give them the following number: "
f"`{ctx.interaction.id if ctx.interaction else ctx.message.id}`"
)
"Our development team has been notified, and the issue will be addressed soon.\n"
"If the error persists, or you have any questions, please contact our [support team]({link}) "
"and give them the extra details below."
).format(link=self.config.bot.support_guild)
details = {}
details['error'] = f"`{repr(e)}`"
if ctx.interaction:
details['interactionid'] = f"`{ctx.interaction.id}`"
if ctx.command:
details['cmd'] = f"`{ctx.command.qualified_name}`"
if ctx.author:
details['author'] = f"`{ctx.author.id}` -- `{ctx.author}`"
details['locale'] = f"`{ctx_locale.get()}`"
if ctx.guild:
details['guild'] = f"`{ctx.guild.id}` -- `{ctx.guild.name}`"
details['my_guild_perms'] = f"`{ctx.guild.me.guild_permissions.value}`"
if ctx.author:
ownerstr = ' (owner)' if ctx.author.id == ctx.guild.owner_id else ''
details['author_guild_perms'] = f"`{ctx.author.guild_permissions.value}{ownerstr}`"
if ctx.channel.type is discord.enums.ChannelType.private:
details['channel'] = "`Direct Message`"
elif ctx.channel:
details['channel'] = f"`{ctx.channel.id}` -- `{ctx.channel.name}`"
details['my_channel_perms'] = f"`{ctx.channel.permissions_for(ctx.guild.me).value}`"
if ctx.author:
details['author_channel_perms'] = f"`{ctx.channel.permissions_for(ctx.author).value}`"
details['shard'] = f"`{self.shardname}`"
details['log_stack'] = f"`{log_action_stack.get()}`"
table = '\n'.join(tabulate(*details.items()))
error_embed.add_field(name='Details', value=table)
try:
await ctx.error_reply(embed=error_embed)
except Exception:
except discord.HTTPException:
pass
finally:
exception.original = HandledException(exception.original)
@@ -232,3 +315,29 @@ class LionBot(Bot):
def add_command(self, command):
if not hasattr(command, '_placeholder_group_'):
super().add_command(command)
def request_chunking_for(self, guild):
if not guild.chunked:
return asyncio.create_task(
self._connection.chunk_guild(guild, wait=False, cache=True),
name=f"Background chunkreq for {guild.id}"
)
async def on_interaction(self, interaction: discord.Interaction):
"""
Adds the interaction author to guild cache if appropriate.
This gets run a little bit late, so it is possible the interaction gets handled
without the author being in case.
"""
guild = interaction.guild
user = interaction.user
if guild is not None and user is not None and isinstance(user, discord.Member):
if not guild.get_member(user.id):
guild._add_member(user)
if guild is not None and not guild.chunked:
# Getting an interaction in the guild is a good enough reason to request chunking
logger.info(
f"Unchunked guild <gid: {guild.id}> requesting chunking after interaction."
)
self.request_chunking_for(guild)

View File

@@ -6,6 +6,7 @@ from typing import Optional, TYPE_CHECKING
import discord
from discord.enums import ChannelType
from discord.ext.commands import Context
from babel.translator import ctx_locale
if TYPE_CHECKING:
from .LionBot import LionBot
@@ -35,6 +36,7 @@ FlatContext = namedtuple(
'interaction',
'guild',
'author',
'channel',
'alias',
'prefix',
'failed')
@@ -78,6 +80,7 @@ class LionContext(Context['LionBot']):
parts['alias'] = f"\"{self.invoked_with}\""
if self.command_failed:
parts['failed'] = self.command_failed
parts['locale'] = f"\"{ctx_locale.get()}\""
return "<LionContext: {}>".format(
' '.join(f"{name}={value}" for name, value in parts.items())

View File

@@ -1,13 +1,19 @@
import logging
import discord
from discord import Interaction
from discord.app_commands import CommandTree
from discord.app_commands.errors import AppCommandError, CommandInvokeError
from discord.enums import InteractionType
from discord.app_commands.namespace import Namespace
from .logger import logging_context, set_logging_context, log_wrap
from utils.lib import tabulate
from gui.errors import RenderingException
from babel.translator import ctx_locale
from .logger import logging_context, set_logging_context, log_wrap, log_action_stack
from .errors import SafeCancellation
from .config import conf
logger = logging.getLogger(__name__)
@@ -17,7 +23,7 @@ class LionTree(CommandTree):
super().__init__(*args, **kwargs)
self._call_tasks = set()
async def on_error(self, interaction, error) -> None:
async def on_error(self, interaction: discord.Interaction, error) -> None:
try:
if isinstance(error, CommandInvokeError):
raise error.original
@@ -26,8 +32,73 @@ class LionTree(CommandTree):
except SafeCancellation:
# Assume this has already been handled
pass
except RenderingException as e:
logger.info(f"Tree interaction failed due to rendering exception: {repr(e)}")
embed = self.rendersplat(e)
await self.error_reply(interaction, embed)
except Exception:
logger.exception(f"Unhandled exception in interaction: {interaction}", extra={'action': 'TreeError'})
embed = self.bugsplat(interaction, error)
await self.error_reply(interaction, embed)
async def error_reply(self, interaction, embed):
if not interaction.is_expired():
try:
if interaction.response.is_done():
await interaction.followup.send(embed=embed, ephemeral=True)
else:
await interaction.response.send_message(embed=embed, ephemeral=True)
except discord.HTTPException:
pass
def rendersplat(self, e: RenderingException):
embed = discord.Embed(
title="Resource Currently Unavailable!",
description=(
"Sorry, the graphics service is currently unavailable!\n"
"Please try again in a few minutes.\n"
"If the error persists, please contact our [support team]({link})"
).format(link=conf.bot.support_guild),
colour=discord.Colour.dark_red()
)
return embed
def bugsplat(self, interaction, e):
error_embed = discord.Embed(title="Something went wrong!", colour=discord.Colour.red())
error_embed.description = (
"An unexpected error occurred during this interaction!\n"
"Our development team has been notified, and the issue will be addressed soon.\n"
"If the error persists, or you have any questions, please contact our [support team]({link}) "
"and give them the extra details below."
).format(link=interaction.client.config.bot.support_guild)
details = {}
details['error'] = f"`{repr(e)}`"
details['interactionid'] = f"`{interaction.id}`"
details['interactiontype'] = f"`{interaction.type}`"
if interaction.command:
details['cmd'] = f"`{interaction.command.qualified_name}`"
details['locale'] = f"`{ctx_locale.get()}`"
if interaction.user:
details['user'] = f"`{interaction.user.id}` -- `{interaction.user}`"
if interaction.guild:
details['guild'] = f"`{interaction.guild.id}` -- `{interaction.guild.name}`"
details['my_guild_perms'] = f"`{interaction.guild.me.guild_permissions.value}`"
if interaction.user:
ownerstr = ' (owner)' if interaction.user.id == interaction.guild.owner_id else ''
details['user_guild_perms'] = f"`{interaction.user.guild_permissions.value}{ownerstr}`"
if interaction.channel.type is discord.enums.ChannelType.private:
details['channel'] = "`Direct Message`"
elif interaction.channel:
details['channel'] = f"`{interaction.channel.id}` -- `{interaction.channel.name}`"
details['my_channel_perms'] = f"`{interaction.channel.permissions_for(interaction.guild.me).value}`"
if interaction.user:
details['user_channel_perms'] = f"`{interaction.channel.permissions_for(interaction.user).value}`"
details['shard'] = f"`{interaction.client.shardname}`"
details['log_stack'] = f"`{log_action_stack.get()}`"
table = '\n'.join(tabulate(*details.items()))
error_embed.add_field(name='Details', value=table)
return error_embed
def _from_interaction(self, interaction: Interaction) -> None:
@log_wrap(context=f"iid: {interaction.id}", isolate=False)

View File

@@ -3,7 +3,7 @@ import configparser as cfgp
from .args import args
shard_number = args.shard or 0
shard_number = args.shard
class configEmoji(PartialEmoji):
__slots__ = ('fallback',)
@@ -87,7 +87,10 @@ class Conf:
"emoji": configEmoji.from_str,
}
)
self.config.read(configfile)
with open(configfile) as conff:
# Opening with read_file mainly to ensure the file exists
self.config.read_file(conff)
self.section_name = section_name if section_name in self.config else 'DEFAULT'

View File

@@ -17,6 +17,7 @@ from .config import conf
from . import sharding
from .context import context
from utils.lib import utc_now
from utils.ratelimits import Bucket, BucketOverFull, BucketFull
log_logger = logging.getLogger(__name__)
@@ -187,6 +188,14 @@ class LessThanFilter(logging.Filter):
# non-zero return means we log this message
return 1 if record.levelno < self.max_level else 0
class ExactLevelFilter(logging.Filter):
def __init__(self, target_level, name=""):
super().__init__(name)
self.target_level = target_level
def filter(self, record):
return (record.levelno == self.target_level)
class ThreadFilter(logging.Filter):
def __init__(self, thread_name):
@@ -233,7 +242,6 @@ class ContextInjection(logging.Filter):
logging_handler_out = logging.StreamHandler(sys.stdout)
logging_handler_out.setLevel(logging.DEBUG)
logging_handler_out.setFormatter(log_fmt)
logging_handler_out.addFilter(LessThanFilter(logging.WARNING))
logging_handler_out.addFilter(ContextInjection())
logger.addHandler(logging_handler_out)
log_logger.addHandler(logging_handler_out)
@@ -258,7 +266,7 @@ class LocalQueueHandler(QueueHandler):
class WebHookHandler(logging.StreamHandler):
def __init__(self, webhook_url, prefix="", batch=False, loop=None):
def __init__(self, webhook_url, prefix="", batch=True, loop=None):
super().__init__()
self.webhook_url = webhook_url
self.prefix = prefix
@@ -270,6 +278,12 @@ class WebHookHandler(logging.StreamHandler):
self.last_batched = None
self.waiting = []
self.bucket = Bucket(20, 40)
self.ignored = 0
self.session = None
self.webhook = None
def get_loop(self):
if self.loop is None:
self.loop = asyncio.new_event_loop()
@@ -281,8 +295,14 @@ class WebHookHandler(logging.StreamHandler):
self.get_loop().call_soon_threadsafe(self._post, record)
def _post(self, record):
if self.session is None:
self.setup()
asyncio.create_task(self.post(record))
def setup(self):
self.session = aiohttp.ClientSession()
self.webhook = Webhook.from_url(self.webhook_url, session=self.session)
async def post(self, record):
log_context.set("Webhook Logger")
log_action_stack.set(("Logging",))
@@ -314,7 +334,7 @@ class WebHookHandler(logging.StreamHandler):
else:
await self._send(message, as_file=as_file)
except Exception as ex:
print(ex)
print(f"Unexpected error occurred while logging to webhook: {repr(ex)}", file=sys.stderr)
async def _schedule_batched(self):
if self.batch_task is not None and not (self.batch_task.done() or self.batch_task.cancelled()):
@@ -327,7 +347,7 @@ class WebHookHandler(logging.StreamHandler):
except asyncio.CancelledError:
return
except Exception as ex:
print(ex)
print(f"Unexpected error occurred while scheduling batched webhook log: {repr(ex)}", file=sys.stderr)
async def _send_batched_now(self):
if self.batch_task is not None and not self.batch_task.done():
@@ -342,18 +362,37 @@ class WebHookHandler(logging.StreamHandler):
await self._send(batched)
async def _send(self, message, as_file=False):
async with aiohttp.ClientSession() as session:
webhook = Webhook.from_url(self.webhook_url, session=session)
if as_file or len(message) > 1900:
with StringIO(message) as fp:
fp.seek(0)
await webhook.send(
f"{self.prefix}\n`{message.splitlines()[0]}`",
file=File(fp, filename="logs.md"),
username=log_app.get()
)
else:
await webhook.send(self.prefix + '\n' + message, username=log_app.get())
try:
self.bucket.request()
except BucketOverFull:
# Silently ignore
self.ignored += 1
return
except BucketFull:
logger.warning(
"Can't keep up! "
"Ignoring records on live-logger {self.webhook.id}."
)
self.ignored += 1
return
else:
if self.ignored > 0:
logger.warning(
"Can't keep up! "
f"{self.ignored} live logging records on webhook {self.webhook.id} skipped, continuing."
)
self.ignored = 0
if as_file or len(message) > 1900:
with StringIO(message) as fp:
fp.seek(0)
await self.webhook.send(
f"{self.prefix}\n`{message.splitlines()[0]}`",
file=File(fp, filename="logs.md"),
username=log_app.get()
)
else:
await self.webhook.send(self.prefix + '\n' + message, username=log_app.get())
handlers = []
@@ -361,8 +400,14 @@ if webhook := conf.logging['general_log']:
handler = WebHookHandler(webhook, batch=True)
handlers.append(handler)
if webhook := conf.logging['warning_log']:
handler = WebHookHandler(webhook, prefix=conf.logging['warning_prefix'], batch=True)
handler.addFilter(ExactLevelFilter(logging.WARNING))
handler.setLevel(logging.WARNING)
handlers.append(handler)
if webhook := conf.logging['error_log']:
handler = WebHookHandler(webhook, prefix=conf.logging['error_prefix'], batch=False)
handler = WebHookHandler(webhook, prefix=conf.logging['error_prefix'], batch=True)
handler.setLevel(logging.ERROR)
handlers.append(handler)

139
src/meta/monitor.py Normal file
View File

@@ -0,0 +1,139 @@
import logging
import asyncio
from enum import IntEnum
from collections import deque, ChainMap
import datetime as dt
logger = logging.getLogger(__name__)
class StatusLevel(IntEnum):
ERRORED = -2
UNSURE = -1
WAITING = 0
STARTING = 1
OKAY = 2
@property
def symbol(self):
return symbols[self]
symbols = {
StatusLevel.ERRORED: '🟥',
StatusLevel.UNSURE: '🟧',
StatusLevel.WAITING: '',
StatusLevel.STARTING: '🟫',
StatusLevel.OKAY: '🟩',
}
class ComponentStatus:
def __init__(self, level: StatusLevel, short_formatstr: str, long_formatstr: str, data: dict = {}):
self.level = level
self.short_formatstr = short_formatstr
self.long_formatstr = long_formatstr
self.data = data
self.created_at = dt.datetime.now(tz=dt.timezone.utc)
def format_args(self):
extra = {
'created_at': self.created_at,
'level': self.level,
'symbol': self.level.symbol,
}
return ChainMap(extra, self.data)
@property
def short(self):
return self.short_formatstr.format(**self.format_args())
@property
def long(self):
return self.long_formatstr.format(**self.format_args())
class ComponentMonitor:
_name = None
def __init__(self, name=None, callback=None):
self._callback = callback
self.name = name or self._name
if not self.name:
raise ValueError("ComponentMonitor must have a name")
async def _make_status(self, *args, **kwargs):
if self._callback is not None:
return await self._callback(*args, **kwargs)
else:
raise NotImplementedError
async def status(self) -> ComponentStatus:
try:
status = await self._make_status()
except Exception as e:
logger.exception(
f"Status callback for component '{self.name}' failed. This should not happen."
)
status = ComponentStatus(
level=StatusLevel.UNSURE,
short_formatstr="Status callback for '{name}' failed with error '{error}'",
long_formatstr="Status callback for '{name}' failed with error '{error}'",
data={
'name': self.name,
'error': repr(e)
}
)
return status
class SystemMonitor:
def __init__(self):
self.components = {}
self.recent = deque(maxlen=10)
def add_component(self, component: ComponentMonitor):
self.components[component.name] = component
return component
async def request(self):
"""
Request status from each component.
"""
tasks = {
name: asyncio.create_task(comp.status())
for name, comp in self.components.items()
}
await asyncio.gather(*tasks.values())
status = {
name: await fut for name, fut in tasks.items()
}
self.recent.append(status)
return status
async def _format_summary(self, status_dict: dict[str, ComponentStatus]):
"""
Format a one line summary from a status dict.
"""
freq = {level: 0 for level in StatusLevel}
for status in status_dict.values():
freq[status.level] += 1
summary = '\t'.join(f"{level.symbol} {count}" for level, count in freq.items() if count)
return summary
async def _format_overview(self, status_dict: dict[str, ComponentStatus]):
"""
Format an overview (one line per component) from a status dict.
"""
lines = []
for name, status in status_dict.items():
lines.append(f"{status.level.symbol} {name}: {status.short}")
summary = await self._format_summary(status_dict)
return '\n'.join((summary, *lines))
async def get_summary(self):
return await self._format_summary(await self.request())
async def get_overview(self):
return await self._format_overview(await self.request())

View File

@@ -9,10 +9,10 @@ active = [
'.ranks',
'.reminders',
'.shop',
'.tasklist',
'.statistics',
'.pomodoro',
'.rooms',
'.tasklist',
'.rolemenus',
'.member_admin',
'.moderation',

View File

@@ -190,7 +190,7 @@ class Economy(LionCog):
# First fetch the members which currently exist
query = self.bot.core.data.Member.table.select_where(guildid=ctx.guild.id)
query.select('userid').with_no_adapter()
if 2 * len(targets) < len(ctx.guild.members):
if 2 * len(targets) < ctx.guild.member_count:
# More efficient to fetch the targets explicitly
query.where(userid=list(targetids))
existent_rows = await query
@@ -381,13 +381,28 @@ class Economy(LionCog):
if role:
query = MemModel.table.select_where(
(MemModel.guildid == role.guild.id) & (MemModel.coins != 0)
)
query.order_by('coins', ORDER.DESC)
).with_no_adapter()
if not role.is_default():
# Everyone role is handled differently for data efficiency
ids = [target.id for target in targets]
query = query.where(userid=ids)
rows = await query
# First get a summary
summary = await query.select(
_count='COUNT(*)',
_coin_total='SUM(coins)',
)
record = summary[0]
count = record['_count']
total = record['_coin_total']
if count > 0:
# Then get the top 1000 members
query._columns = ()
query.order_by('coins', ORDER.DESC)
query.limit(1000)
rows = await query.select('userid', 'coins')
else:
rows = []
name = t(_p(
'cmd:economy_balance|embed:role_lb|author',
@@ -400,7 +415,7 @@ class Economy(LionCog):
"This server has a total balance of {coin_emoji}**{total}**."
)).format(
coin_emoji=cemoji,
total=sum(row['coins'] for row in rows)
total=total
)
else:
header = t(_p(
@@ -408,9 +423,9 @@ class Economy(LionCog):
"{role_mention} has `{count}` members with non-zero balance, "
"with a total balance of {coin_emoji}**{total}**."
)).format(
count=len(targets),
count=count,
role_mention=role.mention,
total=sum(row['coins'] for row in rows),
total=total,
coin_emoji=cemoji
)
@@ -476,7 +491,7 @@ class Economy(LionCog):
else:
# If we have a single target, show their current balance, with a short transaction history.
user = targets[0]
row = await self.bot.core.data.Member.fetch(ctx.guild.id, user.id)
row = await self.bot.core.data.Member.fetch(ctx.guild.id, user.id, cached=False)
embed = discord.Embed(
colour=discord.Colour.orange(),
@@ -675,7 +690,7 @@ class Economy(LionCog):
)
@appcmds.guild_only()
async def send_cmd(self, ctx: LionContext,
target: discord.User | discord.Member,
target: discord.Member,
amount: appcmds.Range[int, 1, MAX_COINS],
note: Optional[str] = None):
"""
@@ -690,17 +705,49 @@ class Economy(LionCog):
t = self.bot.translator.t
error = None
if not ctx.lguild.config.get('allow_transfers').value:
await ctx.interaction.response.send_message(
embed=error_embed(
t(_p(
'cmd:send|error:not_allowed',
"Sorry, this server has disabled LionCoin transfers!"
))
)
error = error_embed(
t(_p(
'cmd:send|error:not_allowed',
"Sorry, this server has disabled LionCoin transfers!"
))
)
elif target == ctx.author:
# Funny response
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
'cmd:send|error:sending-to-self',
"What is this, tax evasion?\n"
"(You can not send coins to yourself.)"
))
)
elif target == ctx.guild.me:
# Funny response
error = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
'cmd:send|error:sending-to-leo',
"I appreciate it, but you need it more than I do!\n"
"(You cannot send coins to bots.)"
))
)
elif target.bot:
# Funny response
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p( # TRANSLATOR NOTE: Easter egg/Funny error, translate as you wish.
'cmd:send|error:sending-to-bot',
"{target} appreciates the gesture, but said they don't have any use for {coin}.\n"
"(You cannot send coins to bots.)"
)).format(target=target.mention, coin=self.bot.config.emojis.coin)
)
if error is not None:
await ctx.interaction.response.send_message(embed=error, ephemeral=True)
return
# Ensure the target member exists
Member = self.bot.core.data.Member
target_lion = await self.bot.core.lions.fetch_member(ctx.guild.id, target.id)
@@ -778,7 +825,7 @@ class Economy(LionCog):
)
)
if failed:
embed.description = t(_p(
embed.description += '\n' + t(_p(
'cmd:send|embed:ack|desc|error:unreachable',
"Unfortunately, I was not able to message the recipient. Perhaps they have me blocked?"
))

View File

@@ -8,6 +8,7 @@ from core.data import CoreData
from utils.data import TemporaryTable, SAFECOINS
# TODO: Add Rank transaction type and tables.
class TransactionType(Enum):
"""
Schema

View File

@@ -181,15 +181,17 @@ class MemberAdminCog(LionCog):
finally:
self._adding_roles.discard((member.guild.id, member.id))
@LionCog.listener('on_member_remove')
@LionCog.listener('on_raw_member_remove')
@log_wrap(action="Farewell")
async def admin_member_farewell(self, member: discord.Member):
async def admin_member_farewell(self, payload: discord.RawMemberRemoveEvent):
# Ignore members that just joined
if (member.guild.id, member.id) in self._adding_roles:
guildid = payload.guild_id
userid = payload.user.id
if (guildid, userid) 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)
lion = await self.bot.core.lions.fetch_member(guildid, userid)
await lion.data.update(last_left=utc_now())
# Save member roles
@@ -197,18 +199,21 @@ class MemberAdminCog(LionCog):
self.bot.db.conn = conn
async with conn.transaction():
await self.data.past_roles.delete_where(
guildid=member.guild.id,
userid=member.id
guildid=guildid,
userid=userid
)
# Insert current member roles
if member.roles:
print(type(payload.user))
if isinstance(payload.user, discord.Member) and payload.user.roles:
member = payload.user
await self.data.past_roles.insert_many(
('guildid', 'userid', 'roleid'),
*((member.guild.id, member.id, role.id) for role in member.roles)
*((guildid, userid, role.id) for role in member.roles)
)
logger.debug(
f"Stored persisting roles for member <uid:{member.id}> in <gid:{member.guild.id}>."
f"Stored persisting roles for member <uid:{userid}> in <gid:{guildid}>."
)
# TODO: Event log, and include info about unchunked members
@LionCog.listener('on_guild_join')
async def admin_init_guild(self, guild: discord.Guild):

View File

@@ -173,7 +173,7 @@ class MemberAdminSettings(SettingGroup):
'{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),
'{member_count}': guild.member_count,
}
recurse_map(
@@ -297,7 +297,7 @@ class MemberAdminSettings(SettingGroup):
'{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)),
'{member_count}': str(guild.member_count),
'{last_time}': str(last_seen or member.joined_at.timestamp()),
}

View File

@@ -210,7 +210,7 @@ class MemberAdminUI(ConfigUI):
t = self.bot.translator.t
title = t(_p(
'ui:memberadmin|embed|title',
"Member Admin Configuration Panel"
"Greetings and Initial Roles Panel"
))
embed = discord.Embed(
title=title,

View File

@@ -32,6 +32,6 @@ class MetaCog(LionCog):
ctx.bot,
ctx.author,
ctx.guild,
show_admin=await low_management(ctx.bot, ctx.author),
show_admin=await low_management(ctx.bot, ctx.author, ctx.guild),
)
await ui.run(ctx.interaction)

View File

@@ -20,9 +20,9 @@ cmd_map = {
"cmd_send": "send",
"cmd_shop": "shop open",
"cmd_room": "room rent",
"cmd_reminders": "remindme in",
"cmd_reminders": "reminders",
"cmd_tasklist": "tasklist",
"cmd_timers": "timers list",
"cmd_timers": "timers",
"cmd_schedule": "schedule",
"cmd_dashboard": "dashboard"
}
@@ -79,8 +79,8 @@ admin_extra = _p(
Other relevant commands for guild configuration below:
`/editshop`: Add/Edit/Remove colour roles from the {coin} shop.
`/ranks`: Add/Edit/Remove activity ranks.
`/timer admin`: Add/Edit/Remove Pomodoro timers in voice channels.
`/ranks`: Add/Edit/Refresh/Remove activity ranks.
`/pomodoro`: Add/Edit/Remove Pomodoro timers in voice channels.
`/rolemenus`: Allow members to equip roles from customisable messages.
`/economy balance`: Display and modify LionCoin balance for members and roles.
"""

View File

@@ -128,7 +128,7 @@ class ModerationSettings(SettingGroup):
_long_desc = _p(
'guildset:mod_role|long_desc',
"Members with the set role will be able to access my configuration panels, "
"and perform some moderation tasks, such us setting up pomodoro timers. "
"and perform some moderation tasks, such as setting up pomodoro timers. "
"Moderators cannot reconfigure most bot configuration, "
"or perform operations they do not already have permission for in Discord."
)

View File

@@ -10,6 +10,7 @@ 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 meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now
from wards import low_management_ward
@@ -21,7 +22,6 @@ from .settings import TimerSettings
from .settingui import TimerConfigUI
from .timer import Timer
from .options import TimerOptions
from .ui import TimerStatusUI
from .ui.config import TimerOptionsUI
_p = babel._p
@@ -43,12 +43,25 @@ class TimerCog(LionCog):
self.bot = bot
self.data = bot.db.load_registry(TimerData())
self.settings = TimerSettings()
self.monitor = ComponentMonitor('TimerCog', self._monitor)
self.timer_options = TimerOptions()
self.ready = False
self.timers = defaultdict(dict)
async def _monitor(self):
if not self.ready:
level = StatusLevel.STARTING
info = "(STARTING) Not ready. {timers} timers loaded."
else:
level = StatusLevel.OKAY
info = "(OK) {timers} timers loaded."
data = dict(timers=len(self.timers))
return ComponentStatus(level, info, info, data)
async def cog_load(self):
self.bot.system_monitor.add_component(self.monitor)
await self.data.init()
self.bot.core.guild_config.register_model_setting(self.settings.PomodoroChannel)
@@ -319,29 +332,24 @@ class TimerCog(LionCog):
await timer.destroy(**kwargs)
# ----- Timer Commands -----
@cmds.hybrid_group(
name=_p('cmd:pomodoro', "timers"),
description=_p('cmd:pomodoro|desc', "Base group for all pomodoro timer commands.")
)
@cmds.guild_only()
async def pomodoro_group(self, ctx: LionContext):
...
# -- User Display Commands --
@pomodoro_group.command(
name=_p('cmd:pomodoro_status', "show"),
description=_p('cmd:pomodoro_status|desc', "Display the status of a single pomodoro timer.")
@cmds.hybrid_command(
name=_p('cmd:timer', "timer"),
description=_p('cmd:timer|desc', "Show your current (or selected) pomodoro timer.")
)
@appcmds.rename(
channel=_p('cmd:pomodoro_status|param:channel', "timer_channel")
channel=_p('cmd:timer|param:channel', "timer_channel")
)
@appcmds.describe(
channel=_p(
'cmd:pomodoro_status|param:channel|desc',
"The channel for which you want to view the timer."
'cmd:timer|param:channel|desc',
"Select a timer to display (by selecting the timer voice channel)"
)
)
async def cmd_pomodoro_status(self, ctx: LionContext, channel: discord.VoiceChannel):
@cmds.guild_only()
async def cmd_timer(self, ctx: LionContext,
channel: Optional[discord.VoiceChannel] = None):
t = self.bot.translator.t
if not ctx.guild:
@@ -349,27 +357,64 @@ class TimerCog(LionCog):
if not ctx.interaction:
return
# Check if a timer exists in the given channel
timer = self.get_channel_timer(channel.id)
if timer is None:
embed = discord.Embed(
timers: list[Timer] = list(self.get_guild_timers(ctx.guild.id).values())
error: Optional[discord.Embed] = None
if not timers:
# Guild has no timers
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_status|error:no_timer',
"The channel {channel} does not have a timer set up!"
)).format(channel=channel.mention)
'cmd:timer|error:no_timers|desc',
"**This server has no timers set up!**\n"
"Ask an admin to set up and configure a timer with {create_cmd} first, "
"or rent a private room with {room_cmd} and create one yourself!"
)).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'),
room_cmd=self.bot.core.mention_cmd('rooms rent'))
)
await ctx.reply(embed=embed, ephemeral=True)
else:
# Display the timer status ephemerally
status = await timer.current_status(with_notify=False, with_warnings=False)
await ctx.reply(**status.send_args, ephemeral=True)
elif channel is None:
if ctx.author.voice and ctx.author.voice.channel:
channel = ctx.author.voice.channel
else:
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:timer|error:no_channel|desc',
"**I don't know what timer to show you.**\n"
"No channel selected and you are not in a voice channel! "
"Use {timers_cmd} to list the available timers in this server."
)).format(timers_cmd=self.bot.core.mention_cmd('timers'))
)
@pomodoro_group.command(
name=_p('cmd:pomodoro_list', "list"),
description=_p('cmd:pomodoro_list|desc', "List the available pomodoro timers.")
if channel is not None:
timer = self.get_channel_timer(channel.id)
if timer is None:
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:timer|error:no_timer_in_channel',
"The channel {channel} is not a pomodoro timer room!\n"
"Use {timers_cmd} to list the available timers in this server."
)).format(
channel=channel.mention,
timers_cmd=self.bot.core.mention_cmd('timers')
)
)
else:
# Display the timer status ephemerally
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
status = await timer.current_status(with_notify=False, with_warnings=False)
await ctx.interaction.edit_original_response(**status.edit_args)
if error is not None:
await ctx.reply(embed=error, ephemeral=True)
@cmds.hybrid_command(
name=_p('cmd:timers', "timers"),
description=_p('cmd:timers|desc', "List the available pomodoro timer rooms.")
)
async def cmd_pomodoro_list(self, ctx: LionContext):
@cmds.guild_only()
async def cmd_timers(self, ctx: LionContext):
t = self.bot.translator.t
if not ctx.guild:
@@ -378,6 +423,8 @@ class TimerCog(LionCog):
return
timers = list(self.get_guild_timers(ctx.guild.id).values())
# Extra filter here to exclude owned timers, but include ones the author is a member of
visible_timers = [
timer for timer in timers
if timer.channel and timer.channel.permissions_for(ctx.author).connect
@@ -385,26 +432,29 @@ class TimerCog(LionCog):
]
if not timers:
# No timers in this guild!
# No timers in the guild
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_list|error:no_timers',
"No timers have been setup in this server!\n"
"You can ask an admin to create one with {command}, "
"or rent a private room and create one yourself!"
)).format(command='`/pomodoro admin create`')
'cmd:timer|error:no_timers|desc',
"**This server has no timers set up!**\n"
"Ask an admin to set up and configure a timer with {create_cmd} first, "
"or rent a private room with {room_cmd} and create one yourself!"
)).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'),
room_cmd=self.bot.core.mention_cmd('rooms rent'))
)
# TODO: Update command mention when we have command mentions
await ctx.reply(embed=embed, ephemeral=True)
elif not visible_timers:
# Timers exist, but the member can't see any
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_list|error:no_visible_timers',
"There are no timers you can join in this server!"
))
'cmd:timer|error:no_visible_timers|desc',
"**There are no available pomodoro timers!**\n"
"Ask an admin to set up a new timer with {create_cmd}, "
"or rent a private room with {room_cmd} and create one yourself!"
)).format(create_cmd=self.bot.core.mention_cmd('pomodoro create'),
room_cmd=self.bot.core.mention_cmd('rooms rent'))
)
await ctx.reply(embed=embed, ephemeral=True)
else:
@@ -412,8 +462,8 @@ class TimerCog(LionCog):
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'cmd:pomodoro_list|embed:timer_list|title',
"Pomodoro Timers in **{guild}**"
'cmd:timers|embed:timer_list|title',
"Pomodoro Timer Rooms in **{guild}**"
)).format(guild=ctx.guild.name),
)
for timer in visible_timers:
@@ -421,25 +471,26 @@ class TimerCog(LionCog):
if stage is None:
if timer.auto_restart:
lazy_status = _p(
'cmd:pomodoro_list|status:stopped_auto',
"`{pattern}` timer is stopped with no members!\nJoin {channel} to restart it."
'cmd:timers|status:stopped_auto',
"`{pattern}` timer is stopped with no members!\n"
"Join {channel} to restart it."
)
else:
lazy_status = _p(
'cmd:pomodoro_list|status:stopped_manual',
'cmd:timers|status:stopped_manual',
"`{pattern}` timer is stopped with `{members}` members!\n"
"Join {channel} and press `Start` to start it!"
)
else:
if stage.focused:
lazy_status = _p(
'cmd:pomodoro_list|status:running_focus',
'cmd:timers|status:running_focus',
"`{pattern}` timer is running with `{members}` members!\n"
"Currently **focusing**, with break starting {timestamp}"
)
else:
lazy_status = _p(
'cmd:pomodoro_list|status:running_break',
'cmd:timers|status:running_break',
"`{pattern}` timer is running with `{members}` members!\n"
"Currently **resting**, with focus starting {timestamp}"
)
@@ -453,18 +504,19 @@ class TimerCog(LionCog):
await ctx.reply(embed=embed, ephemeral=False)
# -- Admin Commands --
@pomodoro_group.group(
name=_p('cmd:pomodoro_admin', "admin"),
desc=_p('cmd:pomodoro_admin|desc', "Command group for pomodoro admin controls.")
@cmds.hybrid_group(
name=_p('cmd:pomodoro', "pomodoro"),
description=_p('cmd:pomodoro|desc', "Create and configure pomodoro timer rooms.")
)
async def pomodoro_admin_group(self, ctx: LionContext):
@cmds.guild_only()
async def pomodoro_group(self, ctx: LionContext):
...
@pomodoro_admin_group.command(
@pomodoro_group.command(
name=_p('cmd:pomodoro_create', "create"),
description=_p(
'cmd:pomodoro_create|desc',
"Create a new Pomodoro timer. Requires admin permissions."
"Create a new Pomodoro timer. Requires manage channel permissions."
)
)
@appcmds.rename(
@@ -497,17 +549,15 @@ class TimerCog(LionCog):
if not ctx.interaction:
return
# Check permissions
if not ctx.author.guild_permissions.administrator:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_create|error:insufficient_perms',
"Only server administrators can create timers!"
))
)
await ctx.reply(embed=embed, ephemeral=True)
return
# Get private room if applicable
room_cog = self.bot.get_cog('RoomCog')
if room_cog is None:
logger.warning("Running pomodoro create without private room cog loaded!")
private_room = None
else:
rooms = room_cog.get_rooms(ctx.guild.id, ctx.author.id)
cid = next((cid for cid, room in rooms.items() if room.data.ownerid == ctx.author.id), None)
private_room = ctx.guild.get_channel(cid) if cid is not None else None
# If a voice channel was not given, attempt to resolve it or make one
if channel is None:
@@ -516,112 +566,155 @@ class TimerCog(LionCog):
channel = ctx.channel
elif ctx.author.voice and ctx.author.voice.channel:
channel = ctx.author.voice.channel
elif not ctx.author.guild_permissions.manage_channels:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'cmd:pomodoro_create|new_channel|error:your_insufficient_perms|title',
"Could not create pomodoro voice channel!"
)),
description=t(_p(
'cmd:pomodoro_create|new_channel|error:your_insufficient_perms',
"No `timer_channel` was provided, and you lack the 'Manage Channels` permission "
"required to create a new timer room!"
))
)
await ctx.reply(embed=embed, ephemeral=True)
elif not ctx.guild.me.guild_permissions.manage_channels:
# Error
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'cmd:pomodoro_create|new_channel|error:my_insufficient_perms|title',
"Could not create pomodoro voice channel!"
)),
description=t(_p(
'cmd:pomodoro_create|new_channel|error:my_insufficient_perms|desc',
"No `timer_channel` was provided, and I lack the 'Manage Channels' permission "
"required to create a new voice channel."
))
)
await ctx.reply(embed=embed, ephemeral=True)
else:
# Attempt to create new channel in current category
if ctx.guild.me.guild_permissions.manage_channels:
try:
channel = await ctx.guild.create_voice_channel(
name=name or "Timer",
reason="Creating Pomodoro Voice Channel",
category=ctx.channel.category
)
except discord.HTTPException:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'cmd:pomodoro_create|error:channel_create_failed|title',
"Could not create pomodoro voice channel!"
)),
description=t(_p(
'cmd:pomodoro_create|error:channel_create|desc',
"Failed to create a new pomodoro voice channel due to an unknown "
"Discord communication error. "
"Please try creating the channel manually and pass it to the "
"`timer_channel` argument of this command."
))
)
await ctx.reply(embed=embed, ephemeral=True)
return
else:
# Error
try:
channel = await ctx.guild.create_voice_channel(
name=name or t(_p(
'cmd:pomodoro_create|new_channel|default_name',
"Timer"
)),
reason=t(_p(
'cmd:pomodoro_create|new_channel|audit_reason',
"Creating Pomodoro Voice Channel"
)),
category=ctx.channel.category
)
except discord.HTTPException:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'cmd:pomodoro_create|error:channel_create_permissions|title',
'cmd:pomodoro_create|new_channel|error:channel_create_failed|title',
"Could not create pomodoro voice channel!"
)),
description=t(_p(
'cmd:pomodoro_create|error:channel_create_permissions|desc',
"No `timer_channel` was provided, and I lack the `MANAGE_CHANNELS` permission "
"needed to create a new voice channel."
'cmd:pomodoro_create|new_channel|error:channel_create_failed|desc',
"Failed to create a new pomodoro voice channel due to an unknown "
"Discord communication error. "
"Please try creating the channel manually and pass it to the "
"`timer_channel` argument of this command."
))
)
await ctx.reply(embed=embed, ephemeral=True)
return
# At this point, we have a voice channel
# Make sure a timer does not already exist in the channel
if (self.get_channel_timer(channel.id)) is not None:
if not channel:
# Already handled the creation error
pass
elif (self.get_channel_timer(channel.id)) is not None:
# A timer already exists in the resolved channel
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_create|error:timer_exists',
"A timer already exists in {channel}! Use `/pomodoro admin edit` to modify it."
)).format(channel=channel.mention)
'cmd:pomodoro_create|add_timer|error:timer_exists',
"A timer already exists in {channel}! "
"Reconfigure it with {edit_cmd}."
)).format(
channel=channel.mention,
edit_cmd=self.bot.core.mention_cmd('pomodoro edit')
)
)
await ctx.reply(embed=embed, ephemeral=True)
return
elif not channel.permissions_for(ctx.author).manage_channels:
# Note that this takes care of private room owners as well
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_create|add_timer|error:your_insufficient_perms',
"You must have the 'Manage Channel' permission in {channel} "
"in order to add a timer there!"
))
)
await ctx.reply(embed=embed, ephemeral=True)
else:
# Finally, we are sure they can create a timer here
# Build the creation arguments from the rest of the provided args
provided = {
'focus_length': focus_length * 60,
'break_length': break_length * 60,
'inactivity_threshold': inactivity_threshold,
'voice_alerts': voice_alerts,
'name': name or channel.name,
'channel_name': channel_name or None,
}
create_args = {'channelid': channel.id, 'guildid': channel.guild.id}
# Build the creation arguments from the rest of the provided args
provided = {
'focus_length': focus_length * 60,
'break_length': break_length * 60,
'notification_channel': notification_channel,
'inactivity_threshold': inactivity_threshold,
'manager_role': manager_role,
'voice_alerts': voice_alerts,
'name': name or channel.name,
'channel_name': channel_name or None,
}
owned = (private_room and (channel == private_room))
if owned:
provided['manager_role'] = manager_role or ctx.guild.default_role
create_args['notification_channelid'] = channel.id
create_args['ownerid'] = ctx.author.id
else:
provided['notification_channel'] = notification_channel
provided['manager_role'] = manager_role
create_args = {'channelid': channel.id, 'guildid': channel.guild.id}
for param, value in provided.items():
if value is not None:
setting, _ = _param_options[param]
create_args[setting._column] = setting._data_from_value(channel.id, value)
for param, value in provided.items():
if value is not None:
setting, _ = _param_options[param]
create_args[setting._column] = setting._data_from_value(channel.id, value)
# Permission checks and input checking done
await ctx.interaction.response.defer(thinking=True)
# Permission checks and input checking done
await ctx.interaction.response.defer(thinking=True)
# Create timer
timer = await self.create_timer(**create_args)
# Create timer
timer = await self.create_timer(**create_args)
# Start timer
await timer.start()
# Start timer
await timer.start()
# Ack with a config UI
ui = TimerOptionsUI(self.bot, timer, TimerRole.ADMIN, callerid=ctx.author.id)
await ui.run(
ctx.interaction,
content=t(_p(
'cmd:pomodoro_create|response:success|content',
"Timer created successfully! Use the panel below to reconfigure."
))
)
await ui.wait()
# Ack with a config UI
ui = TimerOptionsUI(
self.bot, timer, TimerRole.ADMIN if not owned else TimerRole.OWNER, callerid=ctx.author.id
)
await ui.run(
ctx.interaction,
content=t(_p(
'cmd:pomodoro_create|response:success|content',
"Timer created successfully! Use the panel below to reconfigure."
))
)
await ui.wait()
@pomodoro_admin_group.command(
@pomodoro_group.command(
name=_p('cmd:pomodoro_destroy', "destroy"),
description=_p(
'cmd:pomodoro_destroy|desc',
"Delete a pomodoro timer from a voice channel. Requires admin permissions."
"Remove a pomodoro timer from a voice channel."
)
)
@appcmds.rename(
channel=_p('cmd:pomodoro_destroy|param:channel', "timer_channel"),
)
@appcmds.describe(
channel=_p('cmd:pomodoro_destroy|param:channel', "Channel with the timer to delete."),
channel=_p('cmd:pomodoro_destroy|param:channel', "Select a timer voice channel to remove the timer from."),
)
async def cmd_pomodoro_delete(self, ctx: LionContext, channel: discord.VoiceChannel):
t = self.bot.translator.t
@@ -646,46 +739,42 @@ class TimerCog(LionCog):
return
# Check the user has sufficient permissions to delete the timer
# TODO: Should we drop the admin requirement down to manage channel?
timer_role = timer.get_member_role(ctx.author)
if timer.owned:
if timer_role < TimerRole.OWNER:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_destroy|error:insufficient_perms|owned',
"You need to be an administrator or own this channel to remove this timer!"
))
)
await ctx.interaction.response.send_message(embed=embed, ephemeral=True)
return
elif timer_role is not TimerRole.ADMIN:
if timer.owned and timer_role < TimerRole.OWNER:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_destroy|error:insufficient_perms|owned',
"You need to be an administrator or own this channel to remove this timer!"
))
)
await ctx.interaction.response.send_message(embed=embed, ephemeral=True)
elif timer_role is not TimerRole.ADMIN and not channel.permissions_for(ctx.author).manage_channels:
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'cmd:pomodoro_destroy|error:insufficient_perms|notowned',
"You need to be a server administrator to remove this timer!"
))
"You need to have the `Manage Channels` permission in {channel} to remove this timer!"
)).format(channel=channel.mention)
)
await ctx.interaction.response.send_message(embed=embed, ephemeral=True)
return
else:
await ctx.interaction.response.defer(thinking=True)
await self.destroy_timer(timer, reason="Deleted by command")
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:pomdoro_destroy|response:success|description',
"Timer successfully removed from {channel}."
)).format(channel=channel.mention)
)
await ctx.interaction.edit_original_response(embed=embed)
await ctx.interaction.response.defer(thinking=True)
await self.destroy_timer(timer, reason="Deleted by command")
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:pomdoro_destroy|response:success|description',
"Timer successfully removed from {channel}."
)).format(channel=channel.mention)
)
await ctx.interaction.edit_original_response(embed=embed)
@pomodoro_admin_group.command(
@pomodoro_group.command(
name=_p('cmd:pomodoro_edit', "edit"),
description=_p(
'cmd:pomodoro_edit|desc',
"Edit a Timer"
"Reconfigure a pomodoro timer."
)
)
@appcmds.rename(
@@ -695,7 +784,7 @@ class TimerCog(LionCog):
@appcmds.describe(
channel=_p(
'cmd:pomodoro_edit|param:channel|desc',
"Channel holding the timer to edit."
"Select a timer voice channel to reconfigure."
),
**{param: option._desc for param, (option, _) in _param_options.items()}
)
@@ -829,8 +918,6 @@ class TimerCog(LionCog):
@low_management_ward
async def configure_pomodoro_command(self, ctx: LionContext,
pomodoro_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None):
t = self.bot.translator.t
# Type checking guards
if not ctx.guild:
return

View File

@@ -4,6 +4,7 @@ import discord
from meta import LionBot
from meta.errors import UserInputError
from utils.lib import replace_multiple
from babel.translator import ctx_translator
from settings import ModelData
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
@@ -37,6 +38,8 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.channelid.name
_create_row = False
_allow_object = False
@TimerConfig.register_model_setting
class NotificationChannel(ModelData, ChannelSetting):
@@ -50,6 +53,8 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.notification_channelid.name
_create_row = False
_allow_object = False
@classmethod
async def _check_value(cls, parent_id: int, value: Optional[discord.abc.GuildChannel], **kwargs):
@@ -86,6 +91,7 @@ class TimerOptions(SettingGroup):
)
_model = TimerData.Timer
_column = TimerData.Timer.inactivity_threshold.name
_create_row = False
_min = 0
_max = 64
@@ -94,6 +100,19 @@ class TimerOptions(SettingGroup):
def input_formatted(self):
return str(self._data) if self._data is not None else ''
@classmethod
async def _parse_string(cls, parent_id, string, **kwargs):
try:
return await super()._parse_string(parent_id, string, **kwargs)
except UserInputError:
t = ctx_translator.get().t
raise UserInputError(
t(_p(
'timerset:inactivity_length|desc',
"The inactivity threshold must be a positive whole number!"
))
)
@TimerConfig.register_model_setting
class ManagerRole(ModelData, RoleSetting):
setting_id = 'manager_role'
@@ -106,6 +125,8 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.manager_roleid.name
_create_row = False
_allow_object = False
@classmethod
def _format_data(cls, parent_id, data, timer=None, **kwargs):
@@ -132,6 +153,7 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.voice_alerts.name
_create_row = False
@TimerConfig.register_model_setting
class BaseName(ModelData, StringSetting):
@@ -153,6 +175,7 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.pretty_name.name
_create_row = False
@TimerConfig.register_model_setting
class ChannelFormat(ModelData, StringSetting):
@@ -172,6 +195,43 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.channel_name.name
_create_row = False
@classmethod
async def _parse_string(cls, parent_id, string, **kwargs):
# Enforce a length limit on a test-rendered string.
# TODO: Localised formatkey transformation
if string.lower() in ('', 'none', 'default'):
# Special cases for unsetting
return None
testmap = {
'{remaining}': "10m",
'{name}': "Longish name",
'{stage}': "FOCUS",
'{members}': "25",
'{pattern}': "50/10",
}
testmapped = replace_multiple(string, testmap)
if len(testmapped) > 100:
t = ctx_translator.get().t
raise UserInputError(
t(_p(
'timerset:channel_name_format|error:too_long',
"The provided name is too long! Channel names can be at most `100` characters."
))
)
else:
return string
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
"""
Overriding format to truncate displayed string.
"""
if data is not None and len(data) > 100:
data = data[:97] + '...'
return super()._format_data(parent_id, data, **kwargs)
@TimerConfig.register_model_setting
class FocusLength(ModelData, DurationSetting):
@@ -191,6 +251,7 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.focus_length.name
_create_row = False
_default_multiplier = 60
allow_zero = False
@@ -231,6 +292,7 @@ class TimerOptions(SettingGroup):
_model = TimerData.Timer
_column = TimerData.Timer.break_length.name
_create_row = False
_default_multiplier = 60
allow_zero = False

View File

@@ -12,6 +12,7 @@ from utils.lib import MessageArgs, utc_now, replace_multiple
from core.lion_guild import LionGuild
from core.data import CoreData
from babel.translator import ctx_locale
from gui.errors import RenderingException
from . import babel, logger
from .data import TimerData
@@ -45,6 +46,7 @@ class Timer:
'_voice_update_lock',
'_run_task',
'_loop_task',
'destroyed',
)
break_name = _p('timer|stage:break|name', "BREAK")
@@ -79,7 +81,10 @@ class Timer:
# Main loop task. Should not be cancelled.
self._loop_task = None
self.destroyed = False
def __repr__(self):
# TODO: Add lock status and current state and stage
return (
"<Timer "
f"channelid={self.data.channelid} "
@@ -403,7 +408,7 @@ class Timer:
"Remember to press {tick} to register your presence every stage.",
len(needs_kick)
), locale=self.locale.value).format(
channel=self.channel.mention,
channel=f"<#{self.data.channelid}>",
mentions=', '.join(member.mention for member in needs_kick),
tick=self.bot.config.emojis.tick
)
@@ -436,7 +441,7 @@ class Timer:
if not stage:
return
if not self.channel.permissions_for(self.guild.me).speak:
if not self.channel or not self.channel.permissions_for(self.guild.me).speak:
return
async with self.lguild.voice_lock:
@@ -498,7 +503,7 @@ class Timer:
"{channel} is now on **BREAK**! Take a rest, **FOCUS** starts {timestamp}"
)
stageline = t(lazy_stageline).format(
channel=self.channel.mention,
channel=f"<#{self.data.channelid}>",
timestamp=f"<t:{int(stage.end.timestamp())}:R>"
)
return stageline
@@ -555,29 +560,27 @@ class Timer:
content = t(_p(
'timer|status|stopped:auto',
"Timer stopped! Join {channel} to start the timer."
)).format(channel=self.channel.mention)
embed = None
)).format(channel=f"<#{self.data.channelid}>")
else:
content = t(_p(
'timer|status|stopped:manual',
"Timer stopped! Press `Start` to restart the timer."
)).format(channel=self.channel.mention)
embed = None
card = await get_timer_card(self.bot, self, stage)
await card.render()
)).format(channel=f"<#{self.data.channelid}>")
if (ui := self.status_view) is None:
ui = self.status_view = TimerStatusUI(self.bot, self, self.channel)
await ui.refresh()
return MessageArgs(
content=content,
embed=embed,
file=card.as_file(f"pomodoro_{self.data.channelid}.png"),
view=ui
)
card = await get_timer_card(self.bot, self, stage)
try:
await card.render()
file = card.as_file(f"pomodoro_{self.data.channelid}.png")
args = MessageArgs(content=content, file=file, view=ui)
except RenderingException:
args = MessageArgs(content=content, view=ui)
return args
@log_wrap(action='Send Timer Status')
async def send_status(self, delete_last=True, **kwargs):
@@ -676,6 +679,7 @@ class Timer:
if repost:
await self.send_status(delete_last=False, with_notify=False)
@log_wrap(action='Update Channel Name')
async def _update_channel_name(self):
"""
Submit a task to update the voice channel name.
@@ -683,15 +687,19 @@ class Timer:
Attempts to ensure that only one task is running at a time.
Attempts to wait until the next viable channel update slot (via ratelimit).
"""
if self._voice_update_task and not self._voice_update_task.done():
# Voice update request already submitted
if self._voice_update_lock.locked():
# Voice update is already running
# Note that if channel editing takes a long time,
# and the lock is waiting on that,
# we may actually miss a channel update in this period.
# Erring on the side of less ratelimits.
return
async with self._voice_update_lock:
if self._last_voice_update:
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
if to_wait > 0:
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait))
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait), name='timer-voice-wait')
try:
await self._voice_update_task
except asyncio.CancelledError:
@@ -706,8 +714,18 @@ class Timer:
if new_name == self.channel.name:
return
self._last_voice_update = utc_now()
await self.channel.edit(name=self.channel_name)
try:
logger.debug(f"Requesting channel name update for timer {self}")
await self.channel.edit(name=new_name)
except discord.HTTPException:
logger.warning(
f"Voice channel name update failed for timer {self}",
exc_info=True
)
finally:
# Whether we fail or not, update ratelimit marker
# (Repeatedly sending failing requests is even worse than normal ratelimits.)
self._last_voice_update = utc_now()
@log_wrap(action="Stop Timer")
async def stop(self, auto_restart=False):
@@ -736,7 +754,12 @@ class Timer:
if self._run_task and not self._run_task.done():
self._run_task.cancel()
channelid = self.data.channelid
if self.channel:
task = asyncio.create_task(
self.channel.edit(name=self.data.pretty_name, reason="Reverting timer channel name")
)
await self.data.delete()
self.destroyed = True
if self.last_status_message:
try:
await self.last_status_message.delete()
@@ -770,8 +793,8 @@ class Timer:
to_next_stage = (current.end - utc_now()).total_seconds()
# TODO: Consider request rate and load
if to_next_stage > 1 * 60 - drift:
time_to_sleep = 1 * 60
if to_next_stage > 5 * 60 - drift:
time_to_sleep = 5 * 60
else:
time_to_sleep = to_next_stage
@@ -795,6 +818,7 @@ class Timer:
if current.end < utc_now():
self._state = self.current_stage
task = asyncio.create_task(self.notify_change_stage(current, self._state))
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
current = self._state
elif self.members:

View File

@@ -37,6 +37,23 @@ class TimerOptionsUI(MessageUI):
self.timer = timer
self.role = role
async def interaction_check(self, interaction: discord.Interaction):
if self.timer.destroyed:
t = self.bot.translator.t
error = t(_p(
'ui:timer_options|error:timer_destroyed',
"This timer no longer exists! Closing option menu."
))
embed = discord.Embed(
colour=discord.Colour.brand_red(),
description=error
)
await interaction.response.send_message(embed=embed, ephemeral=True)
await self.quit()
return False
else:
return await super().interaction_check(interaction)
@button(label="EDIT_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_button(self, press: discord.Interaction, pressed: Button):
"""

View File

@@ -1,6 +1,7 @@
from typing import Optional
import asyncio
import datetime
from weakref import WeakValueDictionary
import discord
from discord.ext import commands as cmds
@@ -16,6 +17,8 @@ from utils.ui import ChoicedEnum, Transformed
from utils.lib import utc_now, replace_multiple
from utils.ratelimits import Bucket, limit_concurrency
from utils.data import TemporaryTable
from modules.economy.cog import Economy
from modules.economy.data import TransactionType
from . import babel, logger
@@ -126,6 +129,9 @@ class RankCog(LionCog):
# pop the guild whenever the season is updated or the rank type changes.
self._member_ranks = {}
# Weakly referenced Locks for each guild to serialise rank actions
self._rank_locks: dict[int, asyncio.Lock] = WeakValueDictionary()
async def cog_load(self):
await self.data.init()
@@ -136,6 +142,13 @@ class RankCog(LionCog):
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
def ranklock(self, guildid):
lock = self._rank_locks.get(guildid, None)
if lock is None:
lock = self._rank_locks[guildid] = asyncio.Lock()
logger.debug(f"Getting rank lock for guild <guildid: {guildid}> (locked: {lock.locked()})")
return lock
# ---------- Event handlers ----------
# season_start setting event handler.. clears the guild season rank cache
@LionCog.listener('on_guildset_season_start')
@@ -255,50 +268,98 @@ class RankCog(LionCog):
"""
Handle batch of completed message sessions.
"""
tasks = []
# TODO: Thread safety
# TODO: Locking between refresh and individual updates
for guildid, userid, messages, guild_xp in session_data:
lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value
if rank_type in (RankType.MESSAGE, RankType.XP):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
session_rank.stat += messages if (rank_type is RankType.MESSAGE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
async with self.ranklock(guildid):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
session_rank.stat += messages if (rank_type is RankType.MESSAGE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
else:
tasks.append(asyncio.create_task(self._role_check(session_rank)))
if tasks:
await asyncio.gather(*tasks)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
task = asyncio.create_task(self.update_rank(session_rank), name='update-message-rank')
else:
task = asyncio.create_task(self._role_check(session_rank), name='rank-role-check')
await task
async def _role_check(self, session_rank: SeasonRank):
guild = self.bot.get_guild(session_rank.guildid)
member = guild.get_member(session_rank.userid)
crank = session_rank.current_rank
roleid = crank.roleid if crank else None
last_roleid = session_rank.rankrow.last_roleid
if guild is not None and member is not None and roleid != last_roleid:
new_role = guild.get_role(roleid) if roleid else None
last_role = guild.get_role(last_roleid) if last_roleid else None
new_last_roleid = last_roleid
if guild.me.guild_permissions.manage_roles:
try:
if last_role and last_role.is_assignable():
await member.remove_roles(last_role)
new_last_roleid = None
if new_role and new_role.is_assignable():
await member.add_roles(new_role)
new_last_roleid = roleid
except discord.HTTPClient:
pass
if new_last_roleid != last_roleid:
await session_rank.rankrow.update(last_roleid=new_last_roleid)
"""
Update the member's rank roles, if required.
"""
guildid = session_rank.guildid
guild = self.bot.get_guild(guildid)
userid = session_rank.userid
member = guild.get_member(userid)
if guild is not None and member is not None and guild.me.guild_permissions.manage_roles:
ranks = await self.get_guild_ranks(guildid)
crank = session_rank.current_rank
current_roleid = crank.roleid if crank else None
# First gather rank roleids, note that the last_roleid is an 'honourary' roleid
last_roleid = session_rank.rankrow.last_roleid
rank_roleids = {rank.roleid for rank in ranks}
rank_roleids.add(last_roleid)
# Gather member roleids
mem_roleids = {role.id: role for role in member.roles}
# Calculate diffs
to_add = guild.get_role(current_roleid) if (current_roleid not in mem_roleids) else None
to_rm = [
role for roleid, role in mem_roleids.items()
if roleid in rank_roleids and roleid != current_roleid
]
# Now update roles
new_last_roleid = last_roleid
# TODO: Event log here, including errors
to_rm = [role for role in to_rm if role.is_assignable()]
if to_rm:
try:
await member.remove_roles(
*to_rm,
reason="Removing Old Rank Roles",
atomic=True
)
roleids = ', '.join(str(role.id) for role in to_rm)
logger.info(
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
)
new_last_roleid = None
except discord.HTTPException:
logger.warning(
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
exc_info=True
)
if to_add and to_add.is_assignable():
try:
await member.add_roles(
to_add,
reason="Rewarding Activity Rank",
atomic=True
)
logger.info(
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
)
new_last_roleid = to_add.id
except discord.HTTPException:
logger.warning(
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
exc_info=True
)
if new_last_roleid != last_roleid:
await session_rank.rankrow.update(last_roleid=new_last_roleid)
@log_wrap(action="Update Rank")
async def update_rank(self, session_rank):
# Identify target rank
guildid = session_rank.guildid
@@ -326,22 +387,61 @@ class RankCog(LionCog):
if member is None:
return
new_role = guild.get_role(new_rank.roleid)
if last_roleid := session_rank.rankrow.last_roleid:
last_role = guild.get_role(last_roleid)
else:
last_role = None
last_roleid = session_rank.rankrow.last_roleid
# Update ranks
if guild.me.guild_permissions.manage_roles:
try:
if last_role and last_role.is_assignable():
await member.remove_roles(last_role)
# First gather rank roleids, note that the last_roleid is an 'honourary' roleid
rank_roleids = {rank.roleid for rank in ranks}
rank_roleids.add(last_roleid)
# Gather member roleids
mem_roleids = {role.id: role for role in member.roles}
# Calculate diffs
to_add = guild.get_role(new_rank.roleid) if (new_rank.roleid not in mem_roleids) else None
to_rm = [
role for roleid, role in mem_roleids.items()
if roleid in rank_roleids and roleid != new_rank.roleid
]
# Now update roles
# TODO: Event log here, including errors
to_rm = [role for role in to_rm if role.is_assignable()]
if to_rm:
try:
await member.remove_roles(
*to_rm,
reason="Removing Old Rank Roles",
atomic=True
)
roleids = ', '.join(str(role.id) for role in to_rm)
logger.info(
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
)
last_roleid = None
if new_role and new_role.is_assignable():
await member.add_roles(new_role)
last_roleid = new_role.id
except discord.HTTPException:
pass
except discord.HTTPException:
logger.warning(
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
exc_info=True
)
if to_add and to_add.is_assignable():
try:
await member.add_roles(
to_add,
reason="Rewarding Activity Rank",
atomic=True
)
logger.info(
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
)
last_roleid=to_add.id
except discord.HTTPException:
logger.warning(
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
exc_info=True
)
# Update MemberRank row
column = {
@@ -357,6 +457,18 @@ class RankCog(LionCog):
session_rank.current_rank = new_rank
session_rank.next_rank = next((rank for rank in ranks if rank.required > new_rank.required), None)
# Provide economy reward if required
if new_rank.reward:
economy: Economy = self.bot.get_cog('Economy')
await economy.data.Transaction.execute_transaction(
TransactionType.OTHER,
guildid=guildid,
actorid=guild.me.id,
from_account=None,
to_account=userid,
amount=new_rank.reward
)
# Send notification
await self._notify_rank_update(guildid, userid, new_rank)
@@ -415,7 +527,7 @@ class RankCog(LionCog):
required = format_stat_range(rank_type, rank.required, short=False)
key_map = {
'{role_name}': role.name,
'{role_name}': role.name if role else 'Unknown',
'{guild_name}': guild.name,
'{user_name}': member.name,
'{role_id}': role.id,
@@ -427,10 +539,8 @@ class RankCog(LionCog):
}
return key_map
@log_wrap(action="Voice Rank Hook")
async def on_voice_session_complete(self, *session_data):
tasks = []
# TODO: Thread safety
# TODO: Locking between refresh and individual updates
for guildid, userid, duration, guild_xp in session_data:
lguild = await self.bot.core.lions.fetch_guild(guildid)
unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid)
@@ -441,27 +551,28 @@ class RankCog(LionCog):
continue
rank_type = lguild.config.get('rank_type').value
if rank_type in (RankType.VOICE,):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
# TODO: Temporary measure
season_start = lguild.config.get('season_start').value or datetime.datetime(1970, 1, 1)
stat_data = self.bot.get_cog('StatsCog').data
session_rank.stat = (await stat_data.VoiceSessionStats.study_times_since(
guildid, userid, season_start)
)[0]
# session_rank.stat += duration if (rank_type is RankType.VOICE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
async with self.ranklock(guildid):
if (_members := self._member_ranks.get(guildid, None)) is not None and userid in _members:
session_rank = _members[userid]
# TODO: Temporary measure
season_start = lguild.config.get('season_start').value or datetime.datetime(1970, 1, 1)
stat_data = self.bot.get_cog('StatsCog').data
session_rank.stat = (await stat_data.VoiceSessionStats.study_times_since(
guildid, userid, season_start)
)[0]
# session_rank.stat += duration if (rank_type is RankType.VOICE) else guild_xp
else:
session_rank = await self.get_member_rank(guildid, userid)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
tasks.append(asyncio.create_task(self.update_rank(session_rank)))
else:
tasks.append(asyncio.create_task(self._role_check(session_rank)))
if tasks:
await asyncio.gather(*tasks)
if session_rank.next_rank is not None and session_rank.stat > session_rank.next_rank.required:
task = asyncio.create_task(self.update_rank(session_rank), name='voice-rank-update')
else:
task = asyncio.create_task(self._role_check(session_rank), name='voice-role-check')
async def on_xp_update(self, *xp_data):
...
# Currently no-op since xp is given purely by message stats
# Implement if xp ever becomes a combination of message and voice stats
pass
@log_wrap(action='interactive rank refresh')
async def interactive_rank_refresh(self, interaction: discord.Interaction, guild: discord.Guild):
@@ -470,9 +581,10 @@ class RankCog(LionCog):
"""
t = self.bot.translator.t
if not interaction.response.is_done():
await interaction.response.defer(thinking=True, ephemeral=False)
await interaction.response.defer(thinking=False)
ui = RankRefreshUI(self.bot, guild, callerid=interaction.user.id, timeout=None)
await ui.run(interaction)
await ui.send(interaction.channel)
ui.start()
# Retrieve fresh rank roles
ranks = await self.get_guild_ranks(guild.id, refresh=True)
@@ -481,7 +593,15 @@ class RankCog(LionCog):
# Ensure guild is chunked
if not guild.chunked:
members = await guild.chunk()
try:
members = await asyncio.wait_for(guild.chunk(), timeout=60)
except asyncio.TimeoutError:
error = t(_p(
'rank_refresh|error:cannot_chunk|desc',
"Could not retrieve member list from Discord. Please try again later."
))
await ui.set_error(error)
return
else:
members = guild.members
ui.stage_members = True
@@ -638,18 +758,18 @@ class RankCog(LionCog):
# Save correct member ranks and given roles to data
# First clear the member rank data entirely
await self.data.MemberRank.table.delete_where(guildid=guild.id)
column = self._get_rankid_column(rank_type)
values = [
(guild.id, memberid, rank.rankid, rank.roleid)
for memberid, rank in true_member_ranks.items()
]
await self.data.MemberRank.table.insert_many(
('guildid', 'userid', column, 'last_roleid'),
*values
)
if true_member_ranks:
column = self._get_rankid_column(rank_type)
values = [
(guild.id, memberid, rank.rankid, rank.roleid)
for memberid, rank in true_member_ranks.items()
]
await self.data.MemberRank.table.insert_many(
('guildid', 'userid', column, 'last_roleid'),
*values
)
self.flush_guild_ranks(guild.id)
await ui.set_done()
await ui.wait()
# ---------- Commands ----------
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
@@ -671,7 +791,7 @@ class RankCog(LionCog):
await ui.wait()
else:
await ui.reload()
msg = await ui.make_message()
msg = await ui.make_message(show_note=False)
await ctx.reply(
**msg.send_args,
ephemeral=True
@@ -740,7 +860,7 @@ class RankCog(LionCog):
lines = []
if rank_type_setting in modified:
lines.append(rank_type_setting.update_message)
if dm_ranks or rank_channel:
if (dm_ranks is not None) or (rank_channel is not None):
if dm_ranks_setting.value:
if rank_channel_setting.value:
notif_string = t(_p(

View File

@@ -6,11 +6,13 @@ from discord.ui.select import select, Select, SelectOption, RoleSelect
from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from meta.errors import ResponseTimedOut
from core.data import RankType
from data import ORDER
from utils.ui import MessageUI
from utils.ui import MessageUI, Confirm
from utils.lib import MessageArgs
from wards import equippable_role
from babel.translator import ctx_translator
from .. import babel, logger
@@ -30,6 +32,7 @@ class RankOverviewUI(MessageUI):
self.bot = bot
self.guild = guild
self.guildid = guild.id
self.cog = bot.get_cog('RankCog')
self.lguild = None
@@ -98,8 +101,8 @@ class RankOverviewUI(MessageUI):
Refresh the current ranks,
ensuring that all members have the correct rank.
"""
cog = self.bot.get_cog('RankCog')
await cog.interactive_rank_refresh(press, self.guild)
async with self.cog.ranklock(self.guild.id):
await self.cog.interactive_rank_refresh(press, self.guild)
async def refresh_button_refresh(self):
self.refresh_button.label = self.bot.translator.t(_p(
@@ -107,15 +110,38 @@ class RankOverviewUI(MessageUI):
"Refresh Member Ranks"
))
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
async def clear_button(self, press: discord.Interaction, pressed: Button):
"""
Clear the rank list.
"""
await self.rank_model.table.delete_where(guildid=self.guildid)
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
self.ranks = []
await self.redraw()
# Confirm deletion
t = self.bot.translator.t
confirm_msg = t(_p(
'ui:rank_overview|button:clear|confirm',
"Are you sure you want to **delete all activity ranks** in this server?"
))
confirmui = Confirm(confirm_msg, self._callerid)
confirmui.confirm_button.label = t(_p(
'ui:rank_overview|button:clear|confirm|button:yes',
"Yes, clear ranks"
))
confirmui.confirm_button.style = ButtonStyle.red
confirmui.cancel_button.style = ButtonStyle.green
confirmui.cancel_button.label = t(_p(
'ui:rank_overview|button:clear|confirm|button:no',
"Cancel"
))
try:
result = await confirmui.ask(press, ephemeral=True)
except ResponseTimedOut:
result = False
if result:
async with self.cog.ranklock(self.guild.id):
await self.rank_model.table.delete_where(guildid=self.guildid)
self.cog.flush_guild_ranks(self.guild.id)
self.ranks = []
await self.redraw()
async def clear_button_refresh(self):
self.clear_button.label = self.bot.translator.t(_p(
@@ -160,25 +186,11 @@ class RankOverviewUI(MessageUI):
or edit an existing rank,
or throw an error if the role is @everyone or not manageable by the client.
"""
role: discord.Role = selected.values[0]
if role >= selection.user.top_role:
# Do not allow user to manage a role above their own top role
t = self.bot.translator.t
error = t(_p(
'ui:rank_overview|menu:roles|error:above_caller',
"You have insufficient permissions to assign {mention} as a rank role! "
"You may only manage roles below your top role."
)).format(mention=role.mention)
embed = discord.Embed(
title=t(_p(
'ui:rank_overview|menu:roles|error:above_caller|title',
"Insufficient permissions!"
)),
description=error,
colour=discord.Colour.brand_red()
)
await selection.response.send_message(embed=embed, ephemeral=True)
elif role.is_assignable():
if role.is_assignable():
# Create or edit the selected role
existing = next((rank for rank in self.ranks if rank.roleid == role.id), None)
if existing:
# Display and edit the given role
@@ -191,6 +203,8 @@ class RankOverviewUI(MessageUI):
)
else:
# Create new rank based on role
# Need to check the calling author has authority to manage this role
await equippable_role(self.bot, role, selection.user)
await RankEditor.create_rank(
selection,
self.rank_type,
@@ -324,7 +338,7 @@ class RankOverviewUI(MessageUI):
string = f"{start} msgs"
return string
async def make_message(self) -> MessageArgs:
async def make_message(self, show_note=True) -> MessageArgs:
t = self.bot.translator.t
if self.ranks:
@@ -385,6 +399,40 @@ class RankOverviewUI(MessageUI):
title=title,
description=desc
)
if show_note:
# Add note about season start
note_name = t(_p(
'ui:rank_overview|embed|field:note|name',
"Note"
))
season_start = self.lguild.data.season_start
if season_start:
season_str = t(_p(
'ui:rank_overview|embed|field:note|value:with_season',
"Ranks are determined by activity since {timestamp}."
)).format(
timestamp=discord.utils.format_dt(season_start)
)
else:
season_str = t(_p(
'ui:rank_overview|embed|field:note|value:without_season',
"Ranks are determined by *all-time* statistics.\n"
"To reward ranks from a later time (e.g. to have monthly/quarterly/yearly ranks) "
"set the `season_start` with {stats_cmd}"
)).format(stats_cmd=self.bot.core.mention_cmd('configure statistics'))
if self.rank_type is RankType.VOICE:
addendum = t(_p(
'ui:rank_overview|embed|field:note|value|voice_addendum',
"Also note that ranks will only be updated when a member leaves a tracked voice channel! "
"Use the **Refresh Member Ranks** button below to update all members manually."
))
season_str = '\n'.join((season_str, addendum))
embed.add_field(
name=note_name,
value=season_str,
inline=False
)
return MessageArgs(embed=embed)
async def refresh_layout(self):

View File

@@ -7,6 +7,7 @@ from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from core.data import RankType
from wards import equippable_role
from utils.ui import MessageUI, AButton, AsComponents
from utils.lib import MessageArgs, replace_multiple
@@ -112,6 +113,7 @@ class RankPreviewUI(MessageUI):
await submit.response.defer(thinking=False)
if self.parent is not None:
asyncio.create_task(self.parent.refresh())
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
await self.refresh()
@button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red)
@@ -130,6 +132,7 @@ class RankPreviewUI(MessageUI):
role = None
await self.rank.delete()
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
mention = role.mention if role else str(self.rank.roleid)
@@ -212,25 +215,13 @@ class RankPreviewUI(MessageUI):
role: discord.Role = selected.values[0]
await selection.response.defer(thinking=True, ephemeral=True)
if role >= selection.user.top_role:
# Do not allow user to manage a role above their own top role
error = t(_p(
'ui:rank_preview|menu:roles|error:above_caller',
"You have insufficient permissions to assign {mention} as a rank role! "
"You may only manage roles below your top role."
))
embed = discord.Embed(
title=t(_p(
'ui:rank_preview|menu:roles|error:above_caller|title',
"Insufficient permissions!"
)),
description=error,
colour=discord.Colour.brand_red()
)
await selection.response.send_message(embed=embed, ephemeral=True)
elif role.is_assignable():
if role.is_assignable():
# Update the rank role
# Generic permission check for the new role
await equippable_role(self.bot, role, selection.user)
await self.rank.update(roleid=role.id)
self.bot.get_cog('RankCog').flush_guild_ranks(self.guild.id)
if self.parent is not None and not self.parent.is_finished():
asyncio.create_task(self.parent.refresh())
await self.refresh(thinking=selection)

View File

@@ -64,9 +64,12 @@ class RankRefreshUI(MessageUI):
def poke(self):
self._wakeup.set()
def start(self):
self._loop_task = asyncio.create_task(self._refresh_loop(), name='Rank RefreshUI Monitor')
async def run(self, *args, **kwargs):
await super().run(*args, **kwargs)
self._loop_task = asyncio.create_task(self._refresh_loop(), name='refresh ui loop')
self.start()
async def cleanup(self):
if self._loop_task and not self._loop_task.done():
@@ -199,10 +202,11 @@ class RankRefreshUI(MessageUI):
))
value = t(_p(
'ui:refresh_ranks|embed|field:remove|value',
"0 {progress} {total}"
"{progress} {done}/{total} removed"
)).format(
progress=self.progress_bar(self.removed, 0, self.to_remove),
total=self.to_remove,
done=self.removed,
)
embed.add_field(name=name, value=value, inline=False)
else:
@@ -221,10 +225,11 @@ class RankRefreshUI(MessageUI):
))
value = t(_p(
'ui:refresh_ranks|embed|field:add|value',
"0 {progress} {total}"
"{progress} {done}/{total} given"
)).format(
progress=self.progress_bar(self.added, 0, self.to_add),
total=self.to_add,
done=self.added,
)
embed.add_field(name=name, value=value, inline=False)
else:

View File

@@ -12,158 +12,36 @@ Max 25 reminders (propagating Discord restriction)
"""
from typing import Optional
import datetime as dt
from cachetools import TTLCache, LRUCache
from cachetools import TTLCache
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.app_commands import Transform
from discord.ui.select import select, SelectOption
from dateutil.parser import parse, ParserError
from data import RowModel, Registry, WeakCache
from data.queries import ORDER
from data.columns import Integer, String, Timestamp, Bool
from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError
from meta.app import shard_talk, appname_from_shard
from meta.logger import log_wrap, logging_context, set_logging_context
from meta.logger import log_wrap, set_logging_context
from babel import ctx_translator, ctx_locale
from utils.lib import parse_duration, utc_now, strfdur, error_embed
from utils.lib import parse_duration, utc_now, strfdur, error_embed, check_dm
from utils.monitor import TaskMonitor
from utils.transformers import DurationTransformer
from utils.ui import LeoUI, AButton, AsComponents
from utils.ui import AButton, AsComponents
from utils.ratelimits import Bucket
from . import babel, logger
from .data import ReminderData
from .ui import ReminderList
_, _p, _np = babel._, babel._p, babel._np
class ReminderData(Registry, name='reminders'):
class Reminder(RowModel):
"""
Model representing a single reminder.
Since reminders are likely to change across shards,
does not use an explicit reference cache.
Schema
------
CREATE TABLE reminders(
reminderid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
remind_at TIMESTAMPTZ NOT NULL,
content TEXT NOT NULL,
message_link TEXT,
interval INTEGER,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
title TEXT,
footer TEXT
);
CREATE INDEX reminder_users ON reminders (userid);
"""
_tablename_ = 'reminders'
reminderid = Integer(primary=True)
userid = Integer() # User which created the reminder
remind_at = Timestamp() # Time when the reminder should be executed
content = String() # Content the user gave us to remind them
message_link = String() # Link to original confirmation message, for context
interval = Integer() # Repeat interval, if applicable
created_at = Timestamp() # Time when this reminder was originally created
title = String() # Title of the final reminder embed, only set in automated reminders
footer = String() # Footer of the final reminder embed, only set in automated reminders
failed = Bool() # Whether the reminder was already attempted and failed
@property
def timestamp(self) -> int:
"""
Time when this reminder should be executed (next) as an integer timestamp.
"""
return int(self.remind_at.timestamp())
@property
def embed(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
colour=discord.Colour.orange(),
description=self.content,
timestamp=self.remind_at
)
if self.message_link:
embed.add_field(
name=t(_p('reminder|embed', "Context?")),
value="[{click}]({link})".format(
click=t(_p('reminder|embed', "Click Here")),
link=self.message_link
)
)
if self.interval:
embed.add_field(
name=t(_p('reminder|embed', "Next reminder")),
value=f"<t:{self.timestamp + self.interval}:R>"
)
if self.footer:
embed.set_footer(text=self.footer)
return embed
@property
def formatted(self):
"""
Single-line string format for the reminder, intended for an embed.
"""
t = ctx_translator.get().t
content = self.content
trunc_content = content[:50] + '...' * (len(content) > 50)
if interval := self.interval:
if not interval % (24 * 60 * 60):
# Exact day case
days = interval // (24 * 60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every day",
"Every `{days}` days",
days
)).format(days=days)
elif not interval % (60 * 60):
# Exact hour case
hours = interval // (60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every hour",
"Every `{hours}` hours",
hours
)).format(hours=hours)
else:
# Inexact interval, e.g 10m or 1h 10m.
# Use short duration format
repeat = t(_p(
'reminder|formatted|interval',
"Every `{duration}`"
)).format(duration=strfdur(interval))
repeat = f"({repeat})"
else:
repeat = ""
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
jump_link=self.message_link,
content=trunc_content,
timestamp=self.timestamp,
repeat=repeat
)
class ReminderMonitor(TaskMonitor[int]):
...
@@ -191,7 +69,7 @@ class Reminders(LionCog):
# Short term userid -> list[Reminder] cache, mainly for autocomplete
self._user_reminder_cache: TTLCache[int, list[ReminderData.Reminder]] = TTLCache(1000, ttl=60)
self._active_reminderlists: dict[int, ReminderListUI] = {}
self._active_reminderlists: dict[int, ReminderList] = {}
async def cog_load(self):
await self.data.init()
@@ -212,6 +90,105 @@ class Reminders(LionCog):
# Start firing reminders
self.monitor.start()
# ----- Cog API -----
async def create_reminder(
self,
userid: int, remind_at: dt.datetime, content: str,
message_link: Optional[str] = None,
interval: Optional[int] = None,
created_at: Optional[dt.datetime] = None,
) -> ReminderData.Reminder:
"""
Create and schedule a new reminder from user-entered data.
Raises UserInputError if the requested parameters are invalid.
"""
now = utc_now()
if remind_at <= now:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:past',
"The provided reminder time {timestamp} is in the past!"
)).format(timestamp=discord.utils.format_dt(remind_at))
)
if interval is not None and interval < 600:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:too_fast',
"You cannot set a repeating reminder with a period less than 10 minutes."
))
)
existing = await self.data.Reminder.fetch_where(userid=userid)
if len(existing) >= 25:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:too_many',
"Sorry, you have reached the maximum of `25` reminders."
))
)
user = self.bot.get_user(userid)
if not user:
user = await self.bot.fetch_user(userid)
if not user:
raise ValueError(f"Target user {userid} does not exist.")
can_dm = await check_dm(user)
if not can_dm:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'create_reminder|error:cannot_dm',
"I cannot direct message you! Do you have me blocked or direct messages closed?"
))
)
created_at = created_at or now
# Passes validation, actually create
reminder = await self.data.Reminder.create(
userid=userid,
remind_at=remind_at,
content=content,
message_link=message_link,
interval=interval,
created_at=created_at,
)
# Schedule from executor
await self.talk_schedule(reminder.reminderid).send(self.executor_name, wait_for_reply=False)
# Dispatch reminder update
await self.dispatch_update_for(userid)
# Return fresh reminder
return reminder
async def parse_time_static(self, timestr, timezone):
timestr = timestr.strip()
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
if not timestr:
return default
try:
ts = parse(timestr, fuzzy=True, default=default)
except ParserError:
t = self.bot.translator.t
raise UserInputError(
t(_p(
'parse_timestamp|error:parse',
"Could not parse `{given}` as a valid reminder time. "
"Try entering the time in the form `HH:MM` or `YYYY-MM-DD HH:MM`."
)).format(given=timestr)
)
return ts
async def get_reminders_for(self, userid: int):
"""
Retrieve a list of reminders for the given userid, using the cache.
@@ -348,116 +325,43 @@ class Reminders(LionCog):
# Dispatch for analytics
self.bot.dispatch('reminder_sent', reminder)
@cmds.hybrid_group(
name=_p('cmd:reminders', "reminders")
)
async def reminders_group(self, ctx: LionContext):
pass
@reminders_group.command(
# No help string
name=_p('cmd:reminders_show', "show"),
@cmds.hybrid_command(
name=_p('cmd:reminders', "reminders"),
description=_p(
'cmd:reminders_show|desc',
"Display your current reminders."
'cmd:reminders|desc',
"View and set your reminders."
)
)
async def cmd_reminders_show(self, ctx: LionContext):
# No help string
async def cmd_reminders(self, ctx: LionContext):
"""
Display the reminder widget for this user.
"""
t = self.bot.translator.t
if not ctx.interaction:
return
if ctx.author.id in self._active_reminderlists:
await self._active_reminderlists[ctx.author.id].close(
msg=t(_p(
'cmd:reminders_show|close_elsewhere',
"Closing since the list was opened elsewhere."
))
)
ui = ReminderListUI(self.bot, ctx.author)
await self._active_reminderlists[ctx.author.id].quit()
ui = ReminderList(self.bot, ctx.author)
try:
self._active_reminderlists[ctx.author.id] = ui
await ui.run(ctx.interaction)
await ui.run(ctx.interaction, ephemeral=True)
await ui.wait()
finally:
self._active_reminderlists.pop(ctx.author.id, None)
@reminders_group.command(
name=_p('cmd:reminders_clear', "clear"),
description=_p(
'cmd:reminders_clear|desc',
"Clear your reminder list."
)
@cmds.hybrid_group(
name=_p('cmd:remindme', "remindme"),
description=_p('cmd:remindme|desc', "View and set task reminders."),
)
async def cmd_reminders_clear(self, ctx: LionContext):
# No help string
"""
Confirm and then clear all the reminders for this user.
"""
if not ctx.interaction:
return
async def remindme_group(self, ctx: LionContext):
# Base command group for scheduling reminders.
pass
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
if not reminders:
await ctx.reply(
embed=discord.Embed(
description=t(_p(
'cmd:reminders_clear|error:no_reminders',
"You have no reminders to clear!"
)),
colour=discord.Colour.brand_red()
),
ephemeral=True
)
return
embed = discord.Embed(
title=t(_p('cmd:reminders_clear|confirm|title', "Are You Sure?")),
description=t(_np(
'cmd:reminders_clear|confirm|desc',
"Are you sure you want to delete your `{count}` reminder?",
"Are you sure you want to clear your `{count}` reminders?",
len(reminders)
)).format(count=len(reminders))
)
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:yes', "Yes, clear my reminders")))
async def confirm(interaction, press):
await interaction.response.defer()
reminders = await self.data.Reminder.table.delete_where(userid=ctx.author.id)
await self.talk_cancel(*(r['reminderid'] for r in reminders)).send(self.executor_name, wait_for_reply=False)
await ctx.interaction.edit_original_response(
embed=discord.Embed(
description=t(_p(
'cmd:reminders_clear|success|desc',
"Your reminders have been cleared!"
)),
colour=discord.Colour.brand_green()
),
view=None
)
await press.view.close()
await self.dispatch_update_for(ctx.author.id)
@AButton(label=t(_p('cmd:reminders_clear|confirm|button:cancel', "Cancel")))
async def deny(interaction, press):
await interaction.response.defer()
await ctx.interaction.delete_original_response()
await press.view.close()
components = AsComponents(confirm, deny)
await ctx.interaction.response.send_message(embed=embed, view=components, ephemeral=True)
@reminders_group.command(
@remindme_group.command(
name=_p('cmd:reminders_cancel', "cancel"),
description=_p(
'cmd:reminders_cancel|desc',
"Cancel a single reminder. Use the menu in \"reminder show\" to cancel multiple reminders."
"Cancel a single reminder. Use /reminders to clear or cancel multiple reminders."
)
)
@appcmds.rename(
@@ -576,13 +480,6 @@ class Reminders(LionCog):
]
return choices
@cmds.hybrid_group(
name=_p('cmd:remindme', "remindme")
)
async def remindme_group(self, ctx: LionContext):
# Base command group for scheduling reminders.
pass
@remindme_group.command(
name=_p('cmd:remindme_at', "at"),
description=_p(
@@ -596,118 +493,79 @@ class Reminders(LionCog):
every=_p('cmd:remindme_at|param:every', "repeat_every"),
)
@appcmds.describe(
time=_p('cmd:remindme_at|param:time|desc', "When you want to be reminded. (E.g. `4pm` or `16:00`)."),
reminder=_p('cmd:remindme_at|param:reminder|desc', "What should the reminder be?"),
every=_p('cmd:remindme_at|param:every|desc', "How often to repeat this reminder.")
time=_p(
'cmd:remindme_at|param:time|desc',
"When you want to be reminded. (E.g. `4pm` or `16:00`)."
),
reminder=_p(
'cmd:remindme_at|param:reminder|desc',
"What should the reminder be?"
),
every=_p(
'cmd:remindme_at|param:every|desc',
"How often to repeat this reminder."
)
)
async def cmd_remindme_at(
self,
ctx: LionContext,
time: str,
reminder: str,
time: appcmds.Range[str, 1, 100],
reminder: appcmds.Range[str, 1, 2000],
every: Optional[Transform[int, DurationTransformer(60)]] = None
):
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
# Guard against too many reminders
if len(reminders) > 25:
await ctx.error_reply(
embed=error_embed(
t(_p(
'cmd_remindme_at|error:too_many|desc',
"Sorry, you have reached the maximum of `25` reminders!"
)),
title=t(_p(
'cmd_remindme_at|error:too_many|title',
"Could not create reminder!"
))
),
ephemeral=True
)
return
# Guard against too frequent reminders
if every is not None and every < 600:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd_remindme_at|error:too_fast|desc',
"You cannot set a repeating reminder with a period less than 10 minutes."
)),
title=t(_p(
'cmd_remindme_at|error:too_fast|title',
"Could not create reminder!"
))
),
ephemeral=True
)
return
# Parse the provided static time
timezone = ctx.lmember.timezone
time = time.strip()
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
try:
ts = parse(time, fuzzy=True, default=default)
except ParserError:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:remindme_at|error:parse_time|desc',
"Could not parse provided time `{given}`. Try entering e.g. `4 pm` or `16:00`."
)).format(given=time),
title=t(_p(
'cmd:remindme_at|error:parse_time|title',
"Could not create reminder!"
))
),
ephemeral=True
timezone = ctx.lmember.timezone
remind_at = await self.parse_time_static(time, timezone)
reminder = await self.create_reminder(
userid=ctx.author.id,
remind_at=remind_at,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
)
return
if ts < utc_now():
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:remindme_at|error:past_time|desc',
"Provided time is in the past!"
)),
title=t(_p(
'cmd:remindme_at|error:past_time|title',
"Could not create reminder!"
))
),
ephemeral=True
embed = reminder.set_response
except UserInputError as e:
embed = discord.Embed(
title=t(_p(
'cmd:remindme_at|error|title',
"Could not create reminder!"
)),
description=e.msg,
colour=discord.Colour.brand_red()
)
return
# Everything seems to be in order
# Create the reminder
now = utc_now()
rem = await self.data.Reminder.create(
userid=ctx.author.id,
remind_at=ts,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
created_at=now
)
# Reminder created, request scheduling from executor shard
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
# TODO Add repeat to description
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|success|title',
"Reminder Set at {timestamp}"
)).format(timestamp=f"<t:{rem.timestamp}>"),
description=f"> {rem.content}"
)
await ctx.reply(
embed=embed,
ephemeral=True
)
await self.dispatch_update_for(ctx.author.id)
@cmd_remindme_at.autocomplete('time')
async def cmd_remindme_at_acmpl_time(self, interaction: discord.Interaction, partial: str):
if interaction.guild:
lmember = await self.bot.core.lions.fetch_member(interaction.guild.id, interaction.user.id)
timezone = lmember.timezone
else:
luser = await self.bot.core.lions.fetch_user(interaction.user.id)
timezone = luser.timezone
t = self.bot.translator.t
try:
timestamp = await self.parse_time_static(partial, timezone)
choice = appcmds.Choice(
name=timestamp.strftime('%Y-%m-%d %H:%M'),
value=partial
)
except UserInputError:
choice = appcmds.Choice(
name=t(_p(
'cmd:remindme_at|acmpl:time|error:parse',
"Cannot parse \"{partial}\" as a time. Try the format HH:MM or YYYY-MM-DD HH:MM"
)).format(partial=partial),
value=partial
)
return [choice]
@remindme_group.command(
name=_p('cmd:remindme_in', "in"),
@@ -722,228 +580,49 @@ class Reminders(LionCog):
every=_p('cmd:remindme_in|param:every', "repeat_every"),
)
@appcmds.describe(
time=_p('cmd:remindme_in|param:time|desc', "How far into the future to set the reminder (e.g. 1 day 10h 5m)."),
reminder=_p('cmd:remindme_in|param:reminder|desc', "What should the reminder be?"),
every=_p('cmd:remindme_in|param:every|desc', "How often to repeat this reminder. (e.g. 1 day, or 2h)")
time=_p(
'cmd:remindme_in|param:time|desc',
"How far into the future to set the reminder (e.g. 1 day 10h 5m)."
),
reminder=_p(
'cmd:remindme_in|param:reminder|desc',
"What should the reminder be?"
),
every=_p(
'cmd:remindme_in|param:every|desc',
"How often to repeat this reminder. (e.g. 1 day, or 2h)"
)
)
async def cmd_remindme_in(
self,
ctx: LionContext,
time: Transform[int, DurationTransformer(60)],
reminder: appcmds.Range[str, 1, 1000], # TODO: Maximum length 1000?
reminder: appcmds.Range[str, 1, 2000],
every: Optional[Transform[int, DurationTransformer(60)]] = None
):
t = self.bot.translator.t
reminders = await self.data.Reminder.fetch_where(userid=ctx.author.id)
# Guard against too many reminders
if len(reminders) > 25:
await ctx.error_reply(
embed=error_embed(
t(_p(
'cmd_remindme_in|error:too_many|desc',
"Sorry, you have reached the maximum of `25` reminders!"
)),
title=t(_p(
'cmd_remindme_in|error:too_many|title',
"Could not create reminder!"
))
),
ephemeral=True
try:
remind_at = utc_now() + dt.timedelta(seconds=time)
reminder = await self.create_reminder(
userid=ctx.author.id,
remind_at=remind_at,
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
)
return
# Guard against too frequent reminders
if every is not None and every < 600:
await ctx.reply(
embed=error_embed(
t(_p(
'cmd_remindme_in|error:too_fast|desc',
"You cannot set a repeating reminder with a period less than 10 minutes."
)),
title=t(_p(
'cmd_remindme_in|error:too_fast|title',
"Could not create reminder!"
))
),
ephemeral=True
embed = reminder.set_response
except UserInputError as e:
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|error|title',
"Could not create reminder!"
)),
description=e.msg,
colour=discord.Colour.brand_red()
)
return
# Everything seems to be in order
# Create the reminder
now = utc_now()
rem = await self.data.Reminder.create(
userid=ctx.author.id,
remind_at=now + dt.timedelta(seconds=time),
content=reminder,
message_link=ctx.message.jump_url,
interval=every,
created_at=now
)
# Reminder created, request scheduling from executor shard
await self.talk_schedule(rem.reminderid).send(self.executor_name, wait_for_reply=False)
# TODO Add repeat to description
embed = discord.Embed(
title=t(_p(
'cmd:remindme_in|success|title',
"Reminder Set {timestamp}"
)).format(timestamp=f"<t:{rem.timestamp}:R>"),
description=f"> {rem.content}"
)
await ctx.reply(
embed=embed,
ephemeral=True
)
await self.dispatch_update_for(ctx.author.id)
class ReminderListUI(LeoUI):
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.user = user
cog = bot.get_cog('Reminders')
if cog is None:
raise ValueError("Cannot create a ReminderUI without the Reminder cog!")
self.cog: Reminders = cog
self.userid = user.id
# Original interaction which sent the UI message
# Since this is an ephemeral UI, we need this to update and delete
self._interaction: Optional[discord.Interaction] = None
self._reminders = []
async def cleanup(self):
# Cleanup after an ephemeral UI
# Just close if possible
if self._interaction and not self._interaction.is_expired():
try:
await self._interaction.delete_original_response()
except discord.HTTPException:
pass
@select()
async def select_remove(self, interaction: discord.Interaction, selection):
"""
Select a number of reminders to delete.
"""
await interaction.response.defer()
# Hopefully this is a list of reminderids
values = selection.values
# Delete from data
await self.cog.data.Reminder.table.delete_where(reminderid=values)
# Send cancellation
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
self.cog._user_reminder_cache.pop(self.userid, None)
await self.refresh()
async def refresh_select_remove(self):
"""
Refresh the select remove component from current state.
"""
t = self.bot.translator.t
self.select_remove.placeholder = t(_p(
'ui:reminderlist|select:remove|placeholder',
"Select to cancel."
))
self.select_remove.options = [
SelectOption(
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
value=reminder.reminderid,
emoji=self.bot.config.emojis.getemoji('clock')
)
for i, reminder in enumerate(self._reminders, start=1)
]
self.select_remove.min_values = 1
self.select_remove.max_values = len(self._reminders)
async def refresh_reminders(self):
self._reminders = await self.cog.get_reminders_for(self.userid)
async def refresh(self):
"""
Refresh the UI message and components.
"""
if not self._interaction:
raise ValueError("Cannot refresh ephemeral UI without an origin interaction!")
await self.refresh_reminders()
await self.refresh_select_remove()
embed = await self.build_embed()
if self._reminders:
self.set_layout((self.select_remove,))
else:
self.set_layout()
try:
if not self._interaction.response.is_done():
# Fresh message
await self._interaction.response.send_message(embed=embed, view=self, ephemeral=True)
else:
# Update existing message
await self._interaction.edit_original_response(embed=embed, view=self)
except discord.HTTPException:
await self.close()
async def run(self, interaction: discord.Interaction):
"""
Run the UI responding to the given interaction.
"""
self._interaction = interaction
await self.refresh()
async def build_embed(self):
"""
Build the reminder list embed.
"""
t = self.bot.translator.t
reminders = self._reminders
if reminders:
lines = []
num_len = len(str(len(reminders)))
for i, reminder in enumerate(reminders):
lines.append(
"`[{:<{}}]` | {}".format(
i+1,
num_len,
reminder.formatted
)
)
description = '\n'.join(lines)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=utc_now()
).set_author(
name=t(_p(
'ui:reminderlist|embed:list|author',
"{name}'s reminders"
)).format(name=self.user.display_name),
icon_url=self.user.avatar
).set_footer(
text=t(_p(
'ui:reminderlist|embed:list|footer',
"Click a reminder twice to jump to the context!"
))
)
else:
embed = discord.Embed(
description=t(_p(
'ui:reminderlist|embed:no_reminders|desc',
"You have no reminders to display!\n"
"Use {remindme} to create a new reminder."
)).format(
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
)
)
return embed

View File

@@ -0,0 +1,165 @@
import discord
from data import RowModel, Registry
from data.columns import Integer, String, Timestamp, Bool
from babel import ctx_translator
from utils.lib import strfdur
from . import babel
_, _p, _np = babel._, babel._p, babel._np
class ReminderData(Registry, name='reminders'):
class Reminder(RowModel):
"""
Model representing a single reminder.
Since reminders are likely to change across shards,
does not use an explicit reference cache.
Schema
------
CREATE TABLE reminders(
reminderid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE,
remind_at TIMESTAMPTZ NOT NULL,
content TEXT NOT NULL,
message_link TEXT,
interval INTEGER,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
title TEXT,
footer TEXT
);
CREATE INDEX reminder_users ON reminders (userid);
"""
_tablename_ = 'reminders'
reminderid = Integer(primary=True)
userid = Integer() # User which created the reminder
remind_at = Timestamp() # Time when the reminder should be executed
content = String() # Content the user gave us to remind them
message_link = String() # Link to original confirmation message, for context
interval = Integer() # Repeat interval, if applicable
created_at = Timestamp() # Time when this reminder was originally created
title = String() # Title of the final reminder embed, only set in automated reminders
footer = String() # Footer of the final reminder embed, only set in automated reminders
failed = Bool() # Whether the reminder was already attempted and failed
@property
def timestamp(self) -> int:
"""
Time when this reminder should be executed (next) as an integer timestamp.
"""
return int(self.remind_at.timestamp())
@property
def set_response(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=t(_p(
'reminder_set|title',
"Reminder Set!"
)),
description=t(_p(
'reminder_set|desc',
"At {timestamp} I will remind you about:\n"
"> {content}"
)).format(
timestamp=discord.utils.format_dt(self.remind_at),
content=self.content,
)[:2048],
colour=discord.Colour.brand_green(),
)
if self.interval:
embed.add_field(
name=t(_p(
'reminder_set|field:repeat|name',
"Repeats"
)),
value=t(_p(
'reminder_set|field:repeat|value',
"This reminder will repeat every `{interval}` (after the first reminder)."
)).format(interval=strfdur(self.interval, short=False)),
inline=False
)
return embed
@property
def embed(self) -> discord.Embed:
t = ctx_translator.get().t
embed = discord.Embed(
title=self.title or t(_p('reminder|embed', "You asked me to remind you!")),
colour=discord.Colour.orange(),
description=self.content,
timestamp=self.remind_at
)
if self.message_link:
embed.add_field(
name=t(_p('reminder|embed', "Context?")),
value="[{click}]({link})".format(
click=t(_p('reminder|embed', "Click Here")),
link=self.message_link
)
)
if self.interval:
embed.add_field(
name=t(_p('reminder|embed', "Next reminder")),
value=f"<t:{self.timestamp + self.interval}:R>"
)
if self.footer:
embed.set_footer(text=self.footer)
return embed
@property
def formatted(self):
"""
Single-line string format for the reminder, intended for an embed.
"""
t = ctx_translator.get().t
content = self.content
trunc_content = content[:50] + '...' * (len(content) > 50)
if interval := self.interval:
if not interval % (24 * 60 * 60):
# Exact day case
days = interval // (24 * 60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every day",
"Every `{days}` days",
days
)).format(days=days)
elif not interval % (60 * 60):
# Exact hour case
hours = interval // (60 * 60)
repeat = t(_np(
'reminder|formatted|interval',
"Every hour",
"Every `{hours}` hours",
hours
)).format(hours=hours)
else:
# Inexact interval, e.g 10m or 1h 10m.
# Use short duration format
repeat = t(_p(
'reminder|formatted|interval',
"Every `{duration}`"
)).format(duration=strfdur(interval))
repeat = f"({repeat})"
else:
repeat = ""
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
jump_link=self.message_link,
content=trunc_content,
timestamp=self.timestamp,
repeat=repeat
)

304
src/modules/reminders/ui.py Normal file
View File

@@ -0,0 +1,304 @@
from typing import Optional, TYPE_CHECKING
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import LionBot
from meta.errors import UserInputError
from utils.lib import utc_now, MessageArgs, parse_duration
from utils.ui import MessageUI, AButton, AsComponents, ConfigEditor
from . import babel, logger
_, _p, _np = babel._, babel._p, babel._np
if TYPE_CHECKING:
from .cog import Reminders
class ReminderList(MessageUI):
def __init__(self, bot: LionBot, user: discord.User, **kwargs):
super().__init__(callerid=user.id, **kwargs)
self.bot = bot
self.user = user
self.userid = user.id
self.cog: 'Reminders' = bot.get_cog('Reminders')
if self.cog is None:
raise ValueError("Cannot initialise ReminderList without loaded Reminder cog.")
# UI state
self._reminders = []
# ----- UI API -----
# ----- UI Components -----
# Clear button
@button(label="CLEAR_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
async def clear_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
reminders = self._reminders
embed = discord.Embed(
title=t(_p('ui:reminderlist|button:clear|confirm|title', "Are You Sure?")),
description=t(_np(
'ui:reminderlist|button:clear|confirm|desc',
"Are you sure you want to delete your `{count}` reminder?",
"Are you sure you want to clear your `{count}` reminders?",
len(reminders)
)).format(count=len(reminders)),
colour=discord.Colour.dark_orange()
)
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:yes', "Yes, clear my reminders")))
async def confirm(interaction, pressed):
await interaction.response.defer()
reminders = await self.cog.data.Reminder.table.delete_where(userid=self.userid)
await self.cog.talk_cancel(*(r['reminderid'] for r in reminders)).send(
self.cog.executor_name, wait_for_reply=False
)
await press.edit_original_response(
embed=discord.Embed(
description=t(_p(
'ui:reminderlist|button:clear|success|desc',
"Your reminders have been cleared!"
)),
colour=discord.Colour.brand_green()
),
view=None
)
await pressed.view.close()
await self.cog.dispatch_update_for(self.userid)
@AButton(label=t(_p('ui:reminderlist|button:clear|confirm|button:cancel', "Cancel")))
async def deny(interaction, pressed):
await interaction.response.defer()
await press.delete_original_response()
await pressed.view.close()
components = AsComponents(confirm, deny)
await press.response.send_message(embed=embed, view=components, ephemeral=True)
async def clear_button_refresh(self):
self.clear_button.label = self.bot.translator.t(_p(
'ui:reminderlist|button:clear|label',
"Clear Reminders"
))
# New reminder button
@button(label="NEW_BUTTON_PLACEHOLDER", style=ButtonStyle.green)
async def new_button(self, press: discord.Interaction, pressed: Button):
"""
Pop up a modal for the user to enter new reminder information.
"""
t = self.bot.translator.t
if press.guild:
lmember = await self.bot.core.lions.fetch_member(press.guild.id, press.user.id)
timezone = lmember.timezone
else:
luser = await self.bot.core.lions.fetch_user(press.user.id)
timezone = luser.timezone
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
time_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:time|label',
"When would you like to be reminded?"
)),
placeholder=default.strftime('%Y-%m-%d %H:%M'),
required=True,
max_length=100,
)
interval_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:repeat|label',
"How often should the reminder repeat?"
)),
placeholder=t(_p(
'ui:reminderlist|button:new|modal|field:repeat|placeholder',
"1 day 10 hours 5 minutes (Leave empty for no repeat.)"
)),
required=False,
max_length=100,
)
content_field = TextInput(
label=t(_p(
'ui:reminderlist|button:new|modal|field:content|label',
"What should I remind you?"
)),
required=True,
style=TextStyle.long,
max_length=2000,
)
modal = ConfigEditor(
time_field, interval_field, content_field,
title=t(_p(
'ui:reminderlist|button:new|modal|title',
"Set a Reminder"
))
)
@modal.submit_callback()
async def create_reminder(interaction: discord.Interaction):
remind_at = await self.cog.parse_time_static(time_field.value, timezone)
if intervalstr := interval_field.value:
interval = parse_duration(intervalstr)
if interval is None:
raise UserInputError(
t(_p(
'ui:reminderlist|button:new|modal|parse|error:interval',
"Cannot parse '{value}' as a duration."
)).format(value=intervalstr)
)
else:
interval = None
message = await self._original.original_response()
reminder = await self.cog.create_reminder(
userid=self.userid,
remind_at=remind_at,
content=content_field.value,
message_link=message.jump_url,
interval=interval,
)
embed = reminder.set_response
await interaction.response.send_message(embed=embed, ephemeral=True)
await press.response.send_modal(modal)
async def new_button_refresh(self):
self.new_button.label = self.bot.translator.t(_p(
'ui:reminderlist|button:new|label',
"New Reminder"
))
self.new_button.disabled = (len(self._reminders) >= 25)
# Cancel menu
@select(cls=Select, placeholder="CANCEL_REMINDER_PLACEHOLDER", min_values=0, max_values=1)
async def cancel_menu(self, selection: discord.Interaction, selected):
"""
Select a number of reminders to delete.
"""
await selection.response.defer()
if selected.values:
# Hopefully this is a list of reminderids
values = selected.values
# Delete from data
await self.cog.data.Reminder.table.delete_where(reminderid=values)
# Send cancellation
await self.cog.talk_cancel(*values).send(self.cog.executor_name, wait_for_reply=False)
self.cog._user_reminder_cache.pop(self.userid, None)
await self.refresh()
async def cancel_menu_refresh(self):
t = self.bot.translator.t
self.cancel_menu.placeholder = t(_p(
'ui:reminderlist|select:remove|placeholder',
"Select to cancel"
))
self.cancel_menu.options = [
SelectOption(
label=f"[{i}] {reminder.content[:50] + '...' * (len(reminder.content) > 50)}",
value=reminder.reminderid,
emoji=self.bot.config.emojis.getemoji('clock')
)
for i, reminder in enumerate(self._reminders, start=1)
]
self.cancel_menu.min_values = 0
self.cancel_menu.max_values = len(self._reminders)
# ----- UI Flow -----
async def refresh_layout(self):
to_refresh = (
self.cancel_menu_refresh(),
self.new_button_refresh(),
self.clear_button_refresh(),
)
await asyncio.gather(*to_refresh)
if self._reminders:
self.set_layout(
(self.new_button, self.clear_button,),
(self.cancel_menu,),
)
else:
self.set_layout(
(self.new_button,),
)
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
reminders = self._reminders
if reminders:
lines = []
num_len = len(str(len(reminders)))
for i, reminder in enumerate(reminders):
lines.append(
"`[{:<{}}]` | {}".format(
i+1,
num_len,
reminder.formatted
)
)
description = '\n'.join(lines)
embed = discord.Embed(
description=description,
colour=discord.Colour.orange(),
timestamp=utc_now()
).set_author(
name=t(_p(
'ui:reminderlist|embed:list|author',
"Your reminders"
)),
icon_url=self.user.avatar or self.user.default_avatar
).set_footer(
text=t(_p(
'ui:reminderlist|embed:list|footer',
"Click a reminder to jump back to the context!"
))
)
else:
embed = discord.Embed(
title=t(_p(
'ui:reminderlist|embed:no_reminders|title',
"You have no reminders set!"
)).format(
remindme=self.bot.core.cmd_name_cache['remindme'].mention,
),
colour=discord.Colour.dark_orange(),
)
embed.add_field(
name=t(_p(
'ui:reminderlist|embed|tips:name',
"Reminder Tips"
)),
value=t(_p(
'ui:reminderlist|embed|tips:value',
"- Use {at_cmd} to set a reminder at a known time (e.g. `at 10 am`).\n"
"- Use {in_cmd} to set a reminder in a certain time (e.g. `in 2 hours`).\n"
"- Both commands support repeating reminders using the `every` parameter.\n"
"- Remember to tell me your timezone with {timezone_cmd} if you haven't already!"
)).format(
at_cmd=self.bot.core.mention_cmd('remindme at'),
in_cmd=self.bot.core.mention_cmd('remindme in'),
timezone_cmd=self.bot.core.mention_cmd('my timezone'),
)
)
return MessageArgs(embed=embed)
async def reload(self):
self._reminders = await self.cog.get_reminders_for(self.userid)

View File

@@ -287,7 +287,7 @@ class RoleMenuCog(LionCog):
error = None
message = None
splits = msgstr.strip().rsplit('/', maxsplit=2)
splits = msgstr.strip().rsplit('/', maxsplit=2)[-2:]
if len(splits) == 2 and splits[0].isdigit() and splits[1].isdigit():
chid, mid = map(int, splits)
channel = guild.get_channel(chid)
@@ -678,7 +678,7 @@ class RoleMenuCog(LionCog):
target_mine = True
else:
# Parse provided message link into a Message
target_message: discord.Message = await self._parse_msg(message)
target_message: discord.Message = await self._parse_msg(ctx.guild, message)
target_mine = (target_message.author == ctx.guild.me)
# Check that this message is not already attached to a role menu
@@ -747,7 +747,7 @@ class RoleMenuCog(LionCog):
message_data['content'] = target_message.content
if target_message.embeds:
message_data['embed'] = target_message.embeds[0].to_dict()
rawmessage = json.dumps(message_data)
rawmessagedata = json.dumps(message_data)
else:
if rawmessage is not None:
# Attempt to parse rawmessage
@@ -971,14 +971,21 @@ class RoleMenuCog(LionCog):
)
# TODO: Generate the custom message from the template if it doesn't exist
# TODO: Pathway for setting menu style
if rawmessage is not None:
msg_config = target.config.rawmessage
content = await msg_config.download_attachment(rawmessage)
data = await msg_config._parse_string(content)
data = await msg_config._parse_string(0, content)
update_args[msg_config._column] = data
if template is None:
update_args[self.data.RoleMenu.templateid.name] = None
ack_lines.append(msg_config.update_message)
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:custom_message|success',
"Custom menu message updated."
))
)
# Update the data, if applicable
if update_args:
@@ -1185,7 +1192,7 @@ class RoleMenuCog(LionCog):
label: Optional[appcmds.Range[str, 1, 100]] = None,
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
description: Optional[appcmds.Range[str, 0, 100]] = None,
price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
):
# Type checking guards
@@ -1356,7 +1363,7 @@ class RoleMenuCog(LionCog):
await target.update_message()
if target_is_reaction:
try:
await self.menu.update_reactons()
await target.update_reactons()
except SafeCancellation as e:
embed.add_field(
name=t(_p(
@@ -1441,7 +1448,7 @@ class RoleMenuCog(LionCog):
label: Optional[appcmds.Range[str, 1, 100]] = None,
emoji: Optional[appcmds.Range[str, 0, 100]] = None,
description: Optional[appcmds.Range[str, 0, 100]] = None,
price: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
price: Optional[appcmds.Range[int, -MAX_COINS, MAX_COINS]] = None,
duration: Optional[Transform[int, DurationTransformer(60)]] = None,
):
# Type checking wards

View File

@@ -165,7 +165,7 @@ class RoleMenu:
await menu.attach()
return menu
async def fetch_message(self, refresh=False):
async def fetch_message(self, refresh=False) -> Optional[discord.Message]:
"""
Fetch the message the menu is attached to.
"""
@@ -529,11 +529,17 @@ class RoleMenu:
"Role removed"
))
)
if total_refund:
if total_refund > 0:
embed.description = t(_p(
'rolemenu|deselect|success:refund|desc',
"You have removed **{role}**, and been refunded {coin} **{amount}**."
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund)
if total_refund < 0:
# TODO: Consider disallowing them from removing roles if their balance would go negative
embed.description = t(_p(
'rolemenu|deselect|success:negrefund|desc',
"You have removed **{role}**, and have lost {coin} **{amount}**."
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund)
else:
embed.description = t(_p(
'rolemenu|deselect|success:norefund|desc',
@@ -551,7 +557,7 @@ class RoleMenu:
raise UserInputError(
t(_p(
'rolemenu|select|error:required_role',
"You need to have the **{role}** role to use this!"
"You need to have the role **{role}** required to use this menu!"
)).format(role=name)
)
@@ -647,7 +653,7 @@ class RoleMenu:
"Role equipped"
))
)
if price > 0:
if price:
embed.description = t(_p(
'rolemenu|select|success:purchase|desc',
"You have purchased the role **{role}** for {coin}**{amount}**"
@@ -665,6 +671,7 @@ class RoleMenu:
)).format(
timestamp=discord.utils.format_dt(expiry)
)
# TODO Event logging
return embed
async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int):

View File

@@ -1,14 +1,17 @@
from typing import Optional
import discord
from settings import ModelData
from settings.groups import SettingGroup, ModelConfig, SettingDotDict
from settings.setting_types import (
RoleSetting, BoolSetting, StringSetting, DurationSetting, EmojiSetting
RoleSetting, StringSetting, DurationSetting, EmojiSetting
)
from core.setting_types import CoinSetting
from utils.ui import AButton, AsComponents
from meta.errors import UserInputError
from meta import conf
from babel.translator import ctx_translator
from constants import MAX_COINS
from .data import RoleMenuData
from . import babel
@@ -74,6 +77,9 @@ class RoleMenuRoleOptions(SettingGroup):
"This menu item will now give the role {role}."
)).format(role=self.formatted)
return resp
else:
raise ValueError("Attempt to call update_message without a value.")
@RoleMenuRoleConfig.register_model_setting
class Label(ModelData, StringSetting):
@@ -138,7 +144,9 @@ class RoleMenuRoleOptions(SettingGroup):
return button
@classmethod
async def _parse_string(cls, parent_id, string: str, interaction: discord.Interaction = None, **kwargs):
async def _parse_string(cls, parent_id, string: str,
interaction: Optional[discord.Interaction] = None,
**kwargs):
emojistr = await super()._parse_string(parent_id, string, interaction=interaction, **kwargs)
if emojistr and interaction is not None:
# Use the interaction to test
@@ -151,7 +159,7 @@ class RoleMenuRoleOptions(SettingGroup):
view=view,
)
except discord.HTTPException:
t = interaction.client.translator.t
t = ctx_translator.get().t
raise UserInputError(t(_p(
'roleset:emoji|error:test_emoji',
"The selected emoji `{emoji}` is invalid or has been deleted."
@@ -218,34 +226,43 @@ class RoleMenuRoleOptions(SettingGroup):
_display_name = _p('roleset:price', "price")
_desc = _p(
'roleset:price|desc',
"Price of the role, in LionCoins."
"Price of the role, in LionCoins. May be negative."
)
_long_desc = _p(
'roleset:price|long_desc',
"How much the role costs when selected, in LionCoins."
"How many LionCoins should be deducted from a member's account "
"when they equip this role through this menu.\n"
"The price may be negative, in which case the member will instead be rewarded "
"coins when they equip the role."
)
_accepts = _p(
'roleset:price|accepts',
"Amount of coins that the role costs."
"Amount of coins that the role costs when equipped."
)
_default = 0
_min = - MAX_COINS
_model = RoleMenuData.RoleMenuRole
_column = RoleMenuData.RoleMenuRole.price.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
value = self.value or 0
if value > 0:
resp = t(_p(
'roleset:price|set_response:set',
"This role will now cost {price} to equip."
)).format(price=self.formatted)
'roleset:price|set_response:positive',
"Equipping this role will now cost {coin}**{price}**."
)).format(price=value, coin=conf.emojis.coin)
elif value == 0:
resp = t(_p(
'roleset:price|set_response:zero',
"Equipping this role is now free."
))
else:
resp = t(_p(
'roleset:price|set_response:unset',
"This role will now be free to equip from this role menu."
))
'roleset:price|set_response:negative',
"Equipping this role will now reward {coin}**{price}**."
)).format(price=-value, coin=conf.emojis.coin)
return resp
@RoleMenuRoleConfig.register_model_setting

View File

@@ -174,7 +174,7 @@ async def threecolumn_template(menu) -> MessageArgs:
)
async def shop_template(menu) -> MessageArgs:
menuroles = menu.roles
width = max(len(str(menurole.config.price.data)) for menurole in menuroles)
width = max(len(str(menurole.config.price.data)) for menurole in menuroles) if menuroles else 0
lines = []
for menurole in menuroles:

View File

@@ -190,7 +190,10 @@ class MenuEditor(MessageUI):
if not userstr:
new_data = None
else:
new_data = await instance._parse_string(instance.parent_id, userstr)
new_data = await instance._parse_string(
instance.parent_id, userstr,
guildid=self.menu.data.guildid
)
instance.data = new_data
modified.append(instance)
if modified:
@@ -349,7 +352,9 @@ class MenuEditor(MessageUI):
if not userstr:
new_data = None
else:
new_data = await instance._parse_string(instance.parent_id, userstr, interaction=interaction)
new_data = await instance._parse_string(
instance.parent_id, userstr, interaction=interaction
)
instance.data = new_data
modified.append(instance)
if modified:
@@ -644,7 +649,7 @@ class MenuEditor(MessageUI):
self.bot, json.loads(self.menu.data.rawmessage), callback=self._editor_callback, callerid=self._callerid
)
self._slaves.append(editor)
await editor.run(interaction)
await editor.run(interaction, ephemeral=True)
# Template/Custom Menu
@select(cls=Select, placeholder="TEMPLATE_MENU_PLACEHOLDER", min_values=1, max_values=1)
@@ -821,20 +826,15 @@ class MenuEditor(MessageUI):
"""
Display or update the preview message.
"""
args = await self.menu.make_args()
view = await self.menu.make_view()
if self._preview is not None:
try:
await self._preview.delete_original_response()
except discord.HTTPException:
pass
self._preview = None
await press.response.send_message(
**args.send_args,
view=view or discord.utils.MISSING,
ephemeral=True
)
await press.response.defer(thinking=True, ephemeral=True)
self._preview = press
await self.update_preview()
async def preview_button_refresh(self):
t = self.bot.translator.t
@@ -887,13 +887,14 @@ class MenuEditor(MessageUI):
description=desc
)
await selection.edit_original_response(embed=embed)
except discord.HTTPException:
except discord.HTTPException as e:
error = discord.Embed(
colour=discord.Colour.brand_red(),
description=t(_p(
'ui:menu_editor|button:repost|widget:repost|error:post_failed',
"An error ocurred while posting to {channel}. Do I have sufficient permissions?"
)).format(channel=channel.mention)
"An unknown error ocurred while posting to {channel}!\n"
"**Error:** `{exception}`"
)).format(channel=channel.mention, exception=e.text)
)
await selection.edit_original_response(embed=error)
else:
@@ -948,7 +949,7 @@ class MenuEditor(MessageUI):
title=title, description=desc
)
# Send as response with the repost widget attached
await press.response.send_message(embed=embed, view=AsComponents(repost_widget))
await press.response.send_message(embed=embed, view=AsComponents(repost_widget), ephemeral=True)
async def repost_button_refresh(self):
t = self.bot.translator.t
@@ -1039,7 +1040,7 @@ class MenuEditor(MessageUI):
role_index = int(splits[i-1])
mrole = self.menu.roles[role_index]
error = discord.Embed(
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'ui:menu_editor|error:invald_emoji|title',
@@ -1051,7 +1052,7 @@ class MenuEditor(MessageUI):
)).format(emoji=mrole.config.emoji.data, label=mrole.config.label.data)
)
await mrole.data.update(emoji=None)
await self.channel.send(embed=error)
await self.channel.send(embed=embed)
async def _redraw(self, args):
try:

View File

@@ -57,6 +57,21 @@ class RoomCog(LionCog):
for task in self._ticker_tasks.values():
task.cancel()
def get_rooms(self, guildid: int, userid: Optional[int] = None):
"""
Get the private rooms in the given guild, using cache.
If `userid` is provided, filters by rooms which the given user is a member or owner of.
"""
guild_rooms = self._room_cache[guildid]
if userid:
rooms = {
cid: room for cid, room in guild_rooms.items() if userid in room.members or userid == room.data.ownerid
}
else:
rooms = guild_rooms
return rooms
async def _prepare_rooms(self, room_data: list[RoomData.Room]):
"""
Launch or destroy rooms from the provided room data.

View File

@@ -29,12 +29,9 @@ member_overwrite = discord.PermissionOverwrite(
)
owner_overwrite = discord.PermissionOverwrite.from_pair(*member_overwrite.pair())
owner_overwrite.update(
manage_channels=True,
manage_webhooks=True,
manage_channels=True,
manage_messages=True,
create_public_threads=True,
create_private_threads=True,
manage_threads=True,
move_members=True,
)
bot_overwrite = discord.PermissionOverwrite.from_pair(*owner_overwrite.pair())

View File

@@ -233,8 +233,8 @@ class RoomUI(MessageUI):
await submit.edit_original_response(
content=t(_p(
'ui:room_status|button:timer|timer_created',
"Timer created successfully! Use `/pomodoro edit` to configure further."
))
"Timer created successfully! Use {edit_cmd} to configure further."
)).format(edit_cmd=self.bot.core.mention_cmd('pomodoro edit'))
)
await self.refresh()
except UserInputError:

View File

@@ -13,6 +13,7 @@ from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.errors import UserInputError, ResponseTimedOut
from meta.sharding import THIS_SHARD
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now, error_embed
from utils.ui import Confirm
from utils.data import MULTIVALUE_IN, MEMBERS
@@ -38,6 +39,10 @@ class ScheduleCog(LionCog):
self.bot = bot
self.data: ScheduleData = bot.db.load_registry(ScheduleData())
self.settings = ScheduleSettings()
self.monitor = ComponentMonitor(
'ScheduleCog',
self._monitor
)
# Whether we are ready to take events
self.initialised = asyncio.Event()
@@ -57,12 +62,56 @@ class ScheduleCog(LionCog):
self.session_channels = self.settings.SessionChannels._cache
async def _monitor(self):
nowid = self.nowid
now = None
now_lock = self.slotlock(nowid)
if not self.initialised.is_set():
level = StatusLevel.STARTING
info = (
"(STARTING) "
"Not ready. "
"Spawn task is {spawn}. "
"Spawn lock is {spawn_lock}. "
"Active slots {active}."
)
elif nowid not in self.active_slots:
level = StatusLevel.UNSURE
info = (
"(UNSURE) "
"Setup, but current slotid {nowid} not active. "
"Spawn task is {spawn}. "
"Spawn lock is {spawn_lock}. "
"Now lock is {now_lock}. "
"Active slots {active}."
)
else:
now = self.active_slots[nowid]
level = StatusLevel.OKAY
info = (
"(OK) "
"Running current slot {now}. "
"Spawn lock is {spawn_lock}. "
"Now lock is {now_lock}. "
"Active slots {active}."
)
data = {
'spawn': self.spawn_task,
'spawn_lock': self.spawn_lock,
'active': self.active_slots,
'nowid': nowid,
'now_lock': now_lock,
'now': now,
}
return ComponentStatus(level, info, info, data)
@property
def nowid(self):
now = utc_now()
return time_to_slotid(now)
async def cog_load(self):
self.bot.system_monitor.add_component(self.monitor)
await self.data.init()
# Update the session channel cache

View File

@@ -253,6 +253,12 @@ class ScheduledSession:
overwrites = room.overwrites
for member in members:
mobj = guild.get_member(member.userid)
if not mobj and not guild.chunked:
self.bot.request_chunking_for(guild)
try:
mobj = await guild.fetch_member(member.userid)
except discord.HTTPException:
mobj = None
if mobj:
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
try:
@@ -297,6 +303,13 @@ class ScheduledSession:
}
for member in members:
mobj = guild.get_member(member.userid)
if not mobj and not guild.chunked:
self.bot.request_chunking_for(guild)
try:
mobj = await guild.fetch_member(member.userid)
except discord.HTTPException:
mobj = None
if mobj:
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
try:

View File

@@ -440,7 +440,7 @@ class TimeSlot:
)
def launch(self) -> asyncio.Task:
self.run_task = asyncio.create_task(self.run())
self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}")
return self.run_task
@log_wrap(action="TimeSlot Run")

View File

@@ -114,8 +114,10 @@ class Shopping(LionCog):
return
@cmds.hybrid_group(
name=_p('group:shop', 'shop')
name=_p('cmd:shop', 'shop'),
description=_p('cmd:shop|desc', "Purchase coloures, roles, and other goodies with LionCoins.")
)
@appcmds.guild_only
async def shop_group(self, ctx: LionContext):
return
@@ -123,6 +125,7 @@ class Shopping(LionCog):
name=_p('cmd:shop_open', 'open'),
description=_p('cmd:shop_open|desc', "Open the server shop.")
)
@appcmds.guild_only
async def shop_open_cmd(self, ctx: LionContext):
"""
Opens the shop UI for the current guild.
@@ -188,8 +191,8 @@ class StoreManager(ui.LeoUI):
Ask the current shop widget to redraw.
"""
self.page_num %= len(self.stores)
await self.stores[self.page_num].refresh()
await self.stores[self.page_num].redraw()
store = self.stores[self.page_num]
await store.refresh()
def make_buttons(self):
"""

View File

@@ -5,7 +5,7 @@ import discord
from discord.ui.button import Button
from meta import LionBot, LionCog
from utils import ui
from utils.ui import MessageUI
from babel.translator import LazyStr
from ..data import ShopData
@@ -165,7 +165,7 @@ class Shop:
return self._store_cls_(self)
class Store(ui.LeoUI):
class Store(MessageUI):
"""
ABC for the UI used to interact with a given shop.
@@ -174,7 +174,7 @@ class Store(ui.LeoUI):
(Note that each Shop instance is specific to a single customer.)
"""
def __init__(self, shop: Shop, interaction: discord.Interaction, **kwargs):
super().__init__(**kwargs)
super().__init__(callerid=interaction.user.id, **kwargs)
# The shop this Store is an interface for
# Client, shop, and customer data is taken from here
@@ -189,36 +189,10 @@ class Store(ui.LeoUI):
self.embed: Optional[discord.Embed] = None
# Current interaction to use
self.interaction: discord.Interaction = interaction
self._original = interaction
# ----- UI API -----
def set_store_row(self, row):
self.store_row = row
for item in row:
self.add_item(item)
async def refresh(self):
"""
Refresh all UI elements.
"""
raise NotImplementedError
async def redraw(self):
"""
Redraw the store UI.
"""
if self.interaction.is_expired():
# This is actually possible,
# If the user keeps using the UI,
# but never closes it until the origin interaction expires
raise ValueError("This interaction has expired!")
if self.embed is None:
await self.refresh()
await self.interaction.edit_original_response(embed=self.embed, view=self)
async def make_embed(self):
"""
Embed page for this shop.
"""
raise NotImplementedError

View File

@@ -8,12 +8,13 @@ from discord import app_commands as appcmds
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button
from meta import conf
from meta import LionCog, LionContext, LionBot
from meta.errors import SafeCancellation
from meta.logger import log_wrap
from utils import ui
from utils.lib import error_embed
from utils.lib import error_embed, MessageArgs
from constants import MAX_COINS
from wards import equippable_role
from .. import babel
@@ -292,6 +293,7 @@ class ColourShop(Shop):
)
except discord.HTTPException:
# Possibly Forbidden, or the role doesn't actually exist anymore (cache failure)
# TODO: Event log
pass
await self.data.MemberInventory.table.delete_where(inventoryid=owned.data.inventoryid)
@@ -414,7 +416,8 @@ class ColourShopping(ShopCog):
item_type=self._shop_cls_._item_type_,
deleted=False
)
if len(current) >= 25:
# Disabled because we can support more than 25 colours
if False and len(current) >= 25:
raise SafeCancellation(
t(_p(
'cmd:editshop_colours_create|error:max_colours',
@@ -709,7 +712,7 @@ class ColourShopping(ShopCog):
item_type=self._shop_cls_._item_type_,
deleted=False
)
if len(current) >= 25:
if False and len(current) >= 25:
raise SafeCancellation(
t(_p(
'cmd:editshop_colours_add|error:max_colours',
@@ -738,7 +741,7 @@ class ColourShopping(ShopCog):
)
# Check that the author has permission to manage this role
if not (ctx.author.guild_permissions.manage_roles and ctx.author.top_role > role):
if not (ctx.author.guild_permissions.manage_roles):
raise SafeCancellation(
t(_p(
'cmd:editshop_colours_add|error:caller_perms',
@@ -747,6 +750,9 @@ class ColourShopping(ShopCog):
)).format(mention=role.mention)
)
# Final catch-all with more general error messages
await equippable_role(self.bot, role, ctx.author)
if role.permissions.administrator:
raise SafeCancellation(
t(_p(
@@ -1016,7 +1022,7 @@ class ColourShopping(ShopCog):
item = items[0]
# Delete the item, respecting the delete setting.
await self.data.ShopItem.table.update_where(itemid=item.itemid, deleted=True)
await self.data.ShopItem.table.update_where(itemid=item.itemid).set(deleted=True)
if delete_role:
role = ctx.guild.get_role(item.roleid)
@@ -1093,6 +1099,24 @@ class ColourStore(Store):
"""
shop: ColourShop
page_len = 25
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pagen = 0
self.blocks = [[]]
@property
def page_count(self):
return len(self.blocks)
@property
def this_page(self):
self.pagen %= self.page_count
return self.blocks[self.pagen]
# ----- UI Components -----
@select(placeholder="SELECT_PLACEHOLDER")
async def select_colour(self, interaction: discord.Interaction, selection: Select):
t = self.shop.bot.translator.t
@@ -1143,7 +1167,7 @@ class ColourStore(Store):
selector = self.select_colour
# Get the list of ColourRoleItems that may be purchased
purchasable = self.shop.purchasable()
purchasable = [item for item in self.shop.purchasable() if item in self.this_page]
owned = self.shop.owned()
option_map: dict[int, SelectOption] = {}
@@ -1168,37 +1192,54 @@ class ColourStore(Store):
selector.disabled = False
selector.options = list(option_map.values())
async def refresh(self):
"""
Refresh the UI elements
"""
@button(emoji=conf.emojis.forward)
async def next_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen += 1
await self.refresh()
@button(emoji=conf.emojis.backward)
async def prev_page_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.pagen -= 1
await self.refresh()
# ----- UI Flow -----
async def reload(self):
items = self.shop.items
self.blocks = [
items[i:i+self.page_len] for i in range(0, len(items), self.page_len)
] or [[]]
async def refresh_layout(self):
await self.select_colour_refresh()
if not self.select_colour.options:
self._layout = [self.store_row]
if self.page_count > 1:
buttons = (self.prev_page_button, *self.store_row, self.next_page_button)
else:
self._layout = [(self.select_colour,), self.store_row]
buttons = self.store_row
if not self.select_colour.options:
self._layout = [buttons]
else:
self._layout = [(self.select_colour,), buttons]
self.embed = self.make_embed()
def make_embed(self):
"""
Embed for this shop.
"""
async def make_message(self) -> MessageArgs:
t = self.shop.bot.translator.t
owned = self.shop.owned()
if self.shop.items:
owned = self.shop.owned()
page_items = self.this_page
page_start = self.pagen * self.page_len + 1
lines = []
for i, item in enumerate(self.shop.items):
for i, item in enumerate(page_items):
if owned is not None and item.itemid == owned.itemid:
line = t(_p(
'ui:colourstore|embed|line:owned_item',
"`[{j:02}]` | `{price} LC` | {mention} (You own this!)"
)).format(j=i+1, price=item.price, mention=item.mention)
)).format(j=i+page_start, price=item.price, mention=item.mention)
else:
line = t(_p(
'ui:colourstore|embed|line:item',
"`[{j:02}]` | `{price} LC` | {mention}"
)).format(j=i+1, price=item.price, mention=item.mention)
)).format(j=i+page_start, price=item.price, mention=item.mention)
lines.append(line)
description = '\n'.join(lines)
else:
@@ -1210,4 +1251,23 @@ class ColourStore(Store):
title=t(_p('ui:colourstore|embed|title', "Colour Role Shop")),
description=description
)
return embed
if self.page_count > 1:
footer = t(_p(
'ui:colourstore|embed|footer:paged',
"Page {current}/{total}"
)).format(current=self.pagen + 1, total=self.page_count)
embed.set_footer(text=footer)
if owned:
embed.add_field(
name=t(_p(
'ui:colourstore|embed|field:warning|name',
"Note!"
)),
value=t(_p(
'ui:colourstore|embed|field:warning|value',
"Purchasing a new colour role will *replace* your currently colour "
"{current} without refund!"
)).format(current=owned.mention)
)
return MessageArgs(embed=embed)

View File

@@ -1,3 +1,4 @@
import asyncio
import logging
from typing import Optional
@@ -90,8 +91,12 @@ class StatsCog(LionCog):
)).format(loading=self.bot.config.emojis.loading),
timestamp=utc_now(),
)
await ctx.interaction.response(embed=waiting_embed)
await ctx.guild.chunk()
await ctx.interaction.response.send_message(embed=waiting_embed)
try:
await asyncio.wait_for(ctx.guild.chunk(), timeout=10)
pass
except asyncio.TimeoutError:
pass
else:
await ctx.interaction.response.defer(thinking=True)
ui = LeaderboardUI(self.bot, ctx.author, ctx.guild)
@@ -141,13 +146,7 @@ class StatsCog(LionCog):
# Send update ack
if modified:
# TODO
description = t(_p(
'cmd:configure_statistics|resp:success|desc',
"Activity ranks and season leaderboard will now be measured from {season_start}."
)).format(
season_start=setting_season_start.formatted
)
description = setting_season_start.update_message
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=description

View File

@@ -539,21 +539,21 @@ class StatsData(Registry):
_timestamp = Timestamp()
@classmethod
async def fetch_tags(self, guildid: Optional[int], userid: int):
tags = await self.fetch_where(guildid=guildid, userid=userid)
async def fetch_tags(cls, guildid: Optional[int], userid: int):
tags = await cls.fetch_where(guildid=guildid, userid=userid).order_by(cls.tagid)
if not tags and guildid is not None:
tags = await self.fetch_where(guildid=None, userid=userid)
tags = await cls.fetch_where(guildid=None, userid=userid)
return [tag.tag for tag in tags]
@classmethod
@log_wrap(action='set_profile_tags')
async def set_tags(self, guildid: Optional[int], userid: int, tags: Iterable[str]):
async with self._connector.connection() as conn:
self._connector.conn = conn
async def set_tags(cls, guildid: Optional[int], userid: int, tags: Iterable[str]):
async with cls._connector.connection() as conn:
cls._connector.conn = conn
async with conn.transaction():
await self.table.delete_where(guildid=guildid, userid=userid)
await cls.table.delete_where(guildid=guildid, userid=userid)
if tags:
await self.table.insert_many(
await cls.table.insert_many(
('guildid', 'userid', 'tag'),
*((guildid, userid, tag) for tag in tags)
)

View File

@@ -6,6 +6,7 @@ from meta import LionBot
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from modules.schedule.lib import time_to_slotid
from .. import logger
from ..data import StatsData
@@ -81,8 +82,33 @@ async def get_goals_card(
middle_completed = (await model.user_messages_between(userid, start, end))[0]
# Compute schedule session progress
# TODO
sessions_complete = 0.5
attendance = None
schedule_cog = bot.get_cog('ScheduleCog')
if schedule_cog:
booking_model = schedule_cog.data.ScheduleSessionMember
startid = time_to_slotid(start)
endid = time_to_slotid(end)
query = booking_model.table.select_where(
booking_model.slotid >= startid,
booking_model.slotid < endid,
userid=userid
)
if guildid:
query.where(guildid=guildid)
query.select(
_booked='COUNT(*)',
_attended='COUNT(*) FILTER (WHERE attended)',
)
query.with_no_adapter()
records = await query
if records:
record = records[0]
attended = record['_attended']
booked = record['_booked']
if booked:
attendance = attended / booked
# Get member profile
if user:
@@ -105,7 +131,7 @@ async def get_goals_card(
tasks_goal=goals['task_goal'],
studied_hours=middle_completed,
studied_goal=middle_goal,
attendance=sessions_complete,
attendance=attendance,
goals=tasks,
date=today,
skin={'mode': mode}

View File

@@ -10,6 +10,7 @@ from tracking.text.data import TextTrackerData
from ..data import StatsData
from ..lib import apply_month_offset
from .. import logger
async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> MonthlyStatsCard:
@@ -65,6 +66,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
current_streak = 0
longest_streak = 0
else:
first_session = first_session.astimezone(lion.timezone)
first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0)
# first_month = first_day.replace(day=1)
@@ -77,7 +79,19 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
requests.append(day)
# Request times between requested days
day_stats = await req(*reqkey, *requests)
if len(requests) > 1:
day_stats = await req(*reqkey, *requests)
else:
day_stats = []
logger.warning(
"Requesting monthly card with no active days. "
f"offset={offset} "
f"first_session={first_session} "
f"today={today} "
f"target_end={target_end} "
f"userid={userid} "
f"guildid={guildid}"
)
# Compute current streak and longest streak
current_streak = 0

View File

@@ -44,7 +44,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
if crank:
roleid = crank.roleid
role = guild.get_role(roleid)
name = role.name if role else str(role.id)
name = role.name if role else 'Unknown Rank'
minimum = crank.required
maximum = nrank.required if nrank else None
rangestr = format_stat_range(rank_type, minimum, maximum)
@@ -63,7 +63,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
if nrank:
roleid = nrank.roleid
role = guild.get_role(roleid)
name = role.name if role else str(role.id)
name = role.name if role else 'Unknown Rank'
minimum = nrank.required
guild_ranks = await ranks.get_guild_ranks(guildid)

View File

@@ -94,7 +94,8 @@ class StatisticsSettings(SettingGroup):
'guildset:season_start|long_desc',
"Activity ranks will be determined based on tracked activity since this time, "
"and the leaderboard will display activity since this time by default. "
"Unset to disable seasons and use all-time statistics instead."
"Unset to disable seasons and use all-time statistics instead.\n"
"Provided dates and times are assumed to be in the guild `timezone`, so set this first!"
)
_accepts = _p(
'guildset:season_start|accepts',
@@ -134,7 +135,8 @@ class StatisticsSettings(SettingGroup):
resp = t(_p(
'guildset:season_start|set_response|set',
"The leaderboard season and activity ranks will now count from {timestamp}. "
"Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately."
"Member ranks will update when they are next active.\n"
"Use {rank_cmd} and press **Refresh Member Ranks** to refresh all ranks immediately."
)).format(
timestamp=self.formatted,
rank_cmd=bot.core.mention_cmd('ranks')
@@ -143,7 +145,8 @@ class StatisticsSettings(SettingGroup):
resp = t(_p(
'guildset:season_start|set_response|unset',
"The leaderboard and activity ranks will now count all-time statistics. "
"Member ranks will update when they are next active. Use {rank_cmd} to refresh immediately."
"Member ranks will update when they are next active.\n"
"Use {rank_cmd} and press **Refresh Member Ranks** to refresh all ranks immediately."
)).format(rank_cmd=bot.core.mention_cmd('ranks'))
return resp

View File

@@ -75,6 +75,8 @@ class LeaderboardUI(StatsUI):
# (type, period) -> (pagen -> Optional[Future[Card]])
self.cache = {}
self.was_chunked: bool = guild.chunked
async def run(self, interaction: discord.Interaction):
self._original = interaction
@@ -90,6 +92,8 @@ class LeaderboardUI(StatsUI):
periods[LBPeriod.DAY] = lguild.today
periods[LBPeriod.WEEK] = lguild.week_start
periods[LBPeriod.MONTH] = lguild.month_start
alltime = (lguild.data.first_joined_at or interaction.guild.created_at).astimezone(lguild.timezone)
periods[LBPeriod.ALLTIME] = alltime
self.period_starts = periods
self.focused = True
@@ -134,6 +138,7 @@ class LeaderboardUI(StatsUI):
# Filter out members which are not in the server and unranked roles and bots
# Usually hits cache
self.was_chunked = self.guild.chunked
unranked_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(self.guild.id)
unranked_roleids = set(unranked_setting.data)
true_leaderboard = []
@@ -297,42 +302,42 @@ class LeaderboardUI(StatsUI):
@button(label="This Season", style=ButtonStyle.grey)
async def season_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.current_period = LBPeriod.SEASON
self.focused = True
await self.refresh(thinking=press)
@button(label="Today", style=ButtonStyle.grey)
async def day_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.current_period = LBPeriod.DAY
self.focused = True
await self.refresh(thinking=press)
@button(label="This Week", style=ButtonStyle.grey)
async def week_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.current_period = LBPeriod.WEEK
self.focused = True
await self.refresh(thinking=press)
@button(label="This Month", style=ButtonStyle.grey)
async def month_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.current_period = LBPeriod.MONTH
self.focused = True
await self.refresh(thinking=press)
@button(label="All Time", style=ButtonStyle.grey)
async def alltime_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.current_period = LBPeriod.ALLTIME
self.focused = True
await self.refresh(thinking=press)
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
await press.response.defer(thinking=True, ephemeral=True)
self.pagen -= 1
self.focused = False
await self.refresh(thinking=press)
@@ -432,28 +437,44 @@ class LeaderboardUI(StatsUI):
"""
Generate UI message arguments from stored data
"""
t = self.bot.translator.t
chunk_warning = t(_p(
'ui:leaderboard|chunk_warning',
"**Note:** Could not retrieve member list from Discord, so some members may be missing. "
"Try again in a minute!"
))
if self.card is not None:
period_start = self.period_starts[self.current_period]
header = t(_p(
'ui:leaderboard|since',
"Counting statistics since {timestamp}"
)).format(timestamp=discord.utils.format_dt(period_start))
if not self.was_chunked:
header = '\n'.join((header, chunk_warning))
args = MessageArgs(
embed=None,
content=header,
file=self.card.as_file('leaderboard.png')
)
else:
t = self.bot.translator.t
if self.stat_type is StatType.VOICE:
empty_description = t(_p(
'ui:leaderboard|mode:voice|message:empty|desc',
"There has been no voice activity in this period!"
"There has been no voice activity since {timestamp}"
))
elif self.stat_type is StatType.TEXT:
empty_description = t(_p(
'ui:leaderboard|mode:text|message:empty|desc',
"There has been no message activity in this period!"
"There has been no message activity since {timestamp}"
))
elif self.stat_type is StatType.ANKI:
empty_description = t(_p(
'ui:leaderboard|mode:anki|message:empty|desc',
"There have been no Anki cards reviewed in this period!"
"There have been no Anki cards reviewed since {timestamp}"
))
empty_description = empty_description.format(
timestamp=discord.utils.format_dt(self.period_starts[self.current_period])
)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
@@ -462,7 +483,11 @@ class LeaderboardUI(StatsUI):
)),
description=empty_description
)
args = MessageArgs(embed=embed, files=[])
args = MessageArgs(
content=chunk_warning if not self.was_chunked else None,
embed=embed,
files=[]
)
return args
async def refresh_components(self):

View File

@@ -483,12 +483,16 @@ class WeeklyMonthlyUI(StatsUI):
).with_connection(conn)
if modified:
# If either goal type was modified, clear the rendered cache and refresh
for page_key, (goalf, statf) in self._card_cache.items():
# If the stat period type is the same as the current period type
if page_key[2].period is self._stat_page.period:
self._card_cache[page_key] = (None, statf)
await self.refresh(thinking=interaction)
# Check whether the UI finished while we were interacting
if not self._stopped.done():
# If either goal type was modified, clear the rendered cache and refresh
for page_key, (goalf, statf) in self._card_cache.items():
# If the stat period type is the same as the current period type
if page_key[2].period is self._stat_page.period:
self._card_cache[page_key] = (None, statf)
await self.refresh(thinking=interaction)
else:
await interaction.delete_original_response()
await press.response.send_modal(modal)
async def edit_button_refresh(self):

View File

@@ -186,6 +186,17 @@ def mk_print(fp: io.StringIO) -> Callable[..., None]:
return _print
def mk_status_printer(bot, printer):
async def _status(details=False):
if details:
status = await bot.system_monitor.get_overview()
else:
status = await bot.system_monitor.get_summary()
printer(status)
return status
return _status
@log_wrap(action="Code Exec")
async def _async(to_eval: str, style='exec'):
newline = '\n' * ('\n' in to_eval)
@@ -202,6 +213,7 @@ async def _async(to_eval: str, style='exec'):
scope['ctx'] = ctx = context.get()
scope['bot'] = ctx_bot.get()
scope['print'] = _print # type: ignore
scope['print_status'] = mk_status_printer(scope['bot'], _print)
try:
if ctx and ctx.message:
@@ -263,10 +275,63 @@ class Exec(LionCog):
description=_("Execute arbitrary code with Exec")
)
@appcmd.describe(
string="Code to execute."
string="Code to execute.",
target="Cross-shard peer to async on."
)
async def async_cmd(self, ctx: LionContext, *, string: Optional[str] = None):
await ExecUI(ctx, string, ExecStyle.EXEC, ephemeral=False).run()
async def async_cmd(self, ctx: LionContext,
string: Optional[str] = None,
target: Optional[str] = None,
):
if target is not None:
if string is None:
try:
ctx.interaction, string = await input(
ctx.interaction, "Cross-shard async", "Code to execute?",
style=discord.TextStyle.long
)
except asyncio.TimeoutError:
return
await ctx.interaction.response.defer(thinking=True)
if target not in shard_talk.peers:
# Invalid target
embed = discord.Embed(
description="Unknown peer {target}",
colour=discord.Colour.brand_red(),
)
await ctx.interaction.edit_original_response(embed=embed)
else:
# Send to given target
result = await self.talk_async(string).send(target)
if len(result) > 1900:
# Send as file
with StringIO(result) as fp:
fp.seek(0)
file = discord.File(fp, filename=f"output-{target}.md")
await ctx.reply(file=file)
elif result:
await ctx.reply(f"```md\n{result}```")
else:
await ctx.reply("Command completed, and had no output.")
else:
await ExecUI(ctx, string, ExecStyle.EXEC, ephemeral=False).run()
async def _peer_acmpl(self, interaction: discord.Interaction, partial: str):
"""
Autocomplete utility for peer targets parameters.
"""
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=partial)
]
return results
async_cmd.autocomplete('target')(_peer_acmpl)
@commands.hybrid_command(
name=_p('command', 'eval'),
@@ -298,7 +363,7 @@ class Exec(LionCog):
except asyncio.TimeoutError:
return
if ctx.interaction:
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
await ctx.interaction.response.defer(thinking=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())
@@ -323,42 +388,35 @@ class Exec(LionCog):
await ctx.reply(file=file)
else:
# Send as message
await ctx.reply(f"```md\n{output}```", ephemeral=True)
await ctx.reply(f"```md\n{output}```")
@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
asyncall_cmd.autocomplete('target')(_peer_acmpl)
@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.")
extension=_("Name of the extension to reload. See autocomplete for options."),
force=_("Whether to force an extension reload even if it doesn't exist.")
)
@appcmd.guilds(*guild_ids)
async def reload_cmd(self, ctx: LionContext, extension: str):
async def reload_cmd(self, ctx: LionContext, extension: str, force: Optional[bool] = False):
"""
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:
exists = (extension in self.bot.extensions)
if not (force or exists):
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}')"
if exists:
string = f"await bot.reload_extension('{extension}')"
else:
string = f"await bot.load_extension('{extension}')"
await ExecUI(ctx, string, ExecStyle.EVAL).run()
@reload_cmd.autocomplete('extension')

View File

@@ -1,3 +1,4 @@
import asyncio
import datetime
import discord
@@ -22,8 +23,8 @@ class GuildLog(LionCog):
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="Owner", value="<@{}>".format(guild.owner_id), inline=False)
embed.add_field(name="Members", value="{}".format(guild.member_count), 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
@@ -35,39 +36,51 @@ class GuildLog(LionCog):
@LionCog.listener('on_guild_join')
@log_wrap(action="Log Guild Join")
async def log_join_guild(self, guild: discord.Guild):
owner = guild.owner
try:
await asyncio.wait_for(guild.chunk(), timeout=60)
except asyncio.TimeoutError:
pass
bots = 0
known = 0
unknown = 0
other_members = set(mem.id for mem in self.bot.get_all_members() if mem.guild != guild)
# TODO: Add info about when we last joined this guild etc once we have it.
for member in guild.members:
if member.bot:
bots += 1
elif member.id in other_members:
known += 1
else:
unknown += 1
if guild.chunked:
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
)
else:
mem_str = (
"`{count}` total members.\n"
"(Could not chunk guild within `60` seconds.)"
).format(count=guild.member_count)
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(
@@ -77,7 +90,7 @@ class GuildLog(LionCog):
)
embed.set_author(name="Joined guild!")
embed.add_field(name="Owner", value="{0} (ID: {0.id})".format(owner), inline=False)
embed.add_field(name="Owner", value="<@{}>".format(guild.owner_id), 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)

View File

@@ -173,7 +173,17 @@ class TasklistCog(LionCog):
if not channel.guild:
return True
channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value
return (channel in channels) or (channel.category in channels)
# Also allow private rooms
roomcog = self.bot.get_cog('RoomCog')
if roomcog:
private_rooms = roomcog.get_rooms(channel.guild.id)
private_channels = {room.data.channelid for room in private_rooms.values()}
else:
logger.warning(
"Fetching tasklist channels before private room cog is loaded!"
)
private_channels = {}
return (channel in channels) or (channel.id in private_channels) or (channel.category in channels)
async def call_tasklist(self, interaction: discord.Interaction):
await interaction.response.defer(thinking=True, ephemeral=True)
@@ -184,9 +194,28 @@ class TasklistCog(LionCog):
tasklist = await Tasklist.fetch(self.bot, self.data, userid)
if await self.is_tasklist_channel(channel):
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None)
await tasklistui.summon(force=True)
await interaction.delete_original_response()
# Check we have permissions to send a regular message here
my_permissions = channel.permissions_for(guild.me)
if not my_permissions.embed_links or not my_permissions.send_messages:
t = self.bot.translator.t
error = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'summon_tasklist|error:insufficient_perms|title',
"Uh-Oh, I cannot do that here!"
)),
description=t(_p(
'summon_tasklist|error:insufficient_perms|desc',
"This channel is configured as a tasklist channel, "
"but I lack the `EMBED_LINKS` or `SEND_MESSAGES` permission here! "
"If you believe this is unintentional, please contact a server administrator."
))
)
await interaction.edit_original_response(embed=error)
else:
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None)
await tasklistui.summon(force=True)
await interaction.delete_original_response()
else:
# Note that this will also close any existing listening tasklists in this channel (for this user)
tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=600)
@@ -212,14 +241,18 @@ class TasklistCog(LionCog):
# Now do the rest of the listening channels
listening = TasklistUI._live_[userid]
for cid, ui in listening.items():
for cid, ui in list(listening.items()):
if channel and channel.id == cid:
# We already did this channel
continue
if cid not in listening:
# UI closed while we were updating
continue
try:
await ui.refresh()
await ui.redraw()
except discord.HTTPException:
await tui.close()
await ui.close()
@cmds.hybrid_command(
name=_p('cmd:tasklist', "tasklist"),
@@ -289,6 +322,16 @@ class TasklistCog(LionCog):
appcmds.Choice(name=task_string, value=label)
for label, task_string in matching
]
elif multi and partial.lower().strip() in ('-', 'all'):
options = [
appcmds.Choice(
name=t(_p(
'argtype:taskid|match:all',
"All tasks"
)),
value='-'
)
]
elif multi and (',' in partial or '-' in partial):
# Try parsing input as a multi-list
try:
@@ -672,7 +715,7 @@ class TasklistCog(LionCog):
@appcmds.describe(
taskidstr=_p(
'cmd:tasks_remove|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-), or `-` to remove all."
),
created_before=_p(
'cmd:tasks_remove|param:created_before|desc',
@@ -718,10 +761,10 @@ class TasklistCog(LionCog):
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
embed=error_embed(t(_p(
'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
))).format(input=taskidstr)
)
return
@@ -742,10 +785,10 @@ class TasklistCog(LionCog):
tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id)
if not tasks:
await ctx.interaction.edit_original_response(
embed=error_embed(
embed=error_embed(t(_p(
'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist matching all the given conditions!"
).format(input=taskidstr)
))).format(input=taskidstr)
)
return
taskids = [task.taskid for task in tasks]
@@ -785,7 +828,7 @@ class TasklistCog(LionCog):
@appcmds.describe(
taskidstr=_p(
'cmd:tasks_tick|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
"List of task numbers or ranges to tick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to tick all."
),
cascade=_p(
'cmd:tasks_tick|param:cascade|desc',
@@ -813,10 +856,10 @@ class TasklistCog(LionCog):
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
embed=error_embed(t(_p(
'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
))).format(input=taskidstr)
)
return
@@ -861,7 +904,7 @@ class TasklistCog(LionCog):
@appcmds.describe(
taskidstr=_p(
'cmd:tasks_untick|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
"List of task numbers or ranges to untick (e.g. 1, 2, 5-7, 8.1-3, 9-) or '-' to untick all."
),
cascade=_p(
'cmd:tasks_untick|param:cascade|desc',
@@ -888,10 +931,10 @@ class TasklistCog(LionCog):
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
embed=error_embed(t(_p(
'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
))).format(input=taskidstr)
)
return

View File

@@ -233,6 +233,9 @@ class Tasklist:
May raise `UserInputError`.
"""
if labelstr.strip().lower() in ('-', 'all'):
return list(self.tasklist.keys())
labelmap = {label: task.taskid for label, task in self.labelled.items()}
splits = labelstr.split(',')

View File

@@ -381,9 +381,10 @@ class TasklistUI(BasePager):
def _format_parent(self, parentid) -> str:
parentstr = ''
if parentid is not None:
task = self.tasklist.tasklist.get(parentid, None)
if task:
parent_label = self.tasklist.format_label(self.tasklist.labelid(parentid)).strip('.')
pair = next(((label, task) for label, task in self.labelled.items() if task.taskid == parentid), None)
if pair is not None:
label, task = pair
parent_label = self.tasklist.format_label(label).strip('.')
parentstr = f"{parent_label}: {task.content}"
return parentstr
@@ -561,8 +562,8 @@ class TasklistUI(BasePager):
label=self.tasklist.format_label(rootlabel).strip('.'),
)
children = {
label: taskid
for label, taskid in labelled.items()
label: task
for label, task in labelled.items()
if all(i == j for i, j in zip(label, rootlabel))
}
this_page = self.this_page
@@ -572,11 +573,13 @@ class TasklistUI(BasePager):
else:
# Only show the children which display
page_children = [
(label, tid) for label, tid in this_page if label in children and tid != rootid
(label, task) for label, task in this_page if label in children and task.taskid != rootid
][:24]
if page_children:
block = [(rootlabel, rootid), *page_children]
# Always add the root task
block = [(rootlabel, self.tasklist.tasklist[rootid]), *page_children]
else:
# There are no subtree children on the current page
block = []
# Special case if the subtree is exactly the same as the page
if not (len(block) == len(this_page) and all(i[0] == j[0] for i, j in zip(block, this_page))):

View File

@@ -396,7 +396,7 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord.
if data is not None:
role = None
guildid = cls._get_guildid(parent_id)
guildid = cls._get_guildid(parent_id, **kwargs)
bot = ctx_bot.get()
guild = bot.get_guild(guildid)
if guild is not None:
@@ -409,11 +409,14 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord.
async def _parse_string(cls, parent_id, string: str, **kwargs):
if not string or string.lower() == 'none':
return None
guildid = cls._get_guildid(parent_id, **kwargs)
t = ctx_translator.get().t
bot = ctx_bot.get()
role = None
guild = bot.get_guild(parent_id)
guild = bot.get_guild(guildid)
if guild is None:
raise ValueError("Attempting to parse role string with no guild.")
if string.isdigit():
maybe_id = int(string)
@@ -1004,7 +1007,7 @@ class TimestampSetting(InteractiveSetting[ParentID, str, dt.datetime]):
@property
def input_formatted(self) -> str:
if self._data:
formatted = self._data.strftime('%Y-%M-%d %H:%M')
formatted = self._data.strftime('%Y-%m-%d %H:%M')
else:
formatted = ''
return formatted

View File

@@ -34,7 +34,7 @@ class TextTrackerConfigUI(ConfigUI):
)
async def untracked_channels_menu(self, selection: discord.Interaction, selected):
await selection.response.defer()
setting = self.instances[3]
setting = self.instances[2]
setting.value = selected.values
await setting.write()

View File

@@ -99,8 +99,16 @@ class VoiceTrackerCog(LionCog):
voice_members = {} # (guildid, userid) -> TrackedVoiceState
voice_guilds = set()
for guild in self.bot.guilds:
untracked = self.untracked_channels.get(guild.id, ())
for channel in guild.voice_channels:
if channel.id in untracked:
continue
if channel.category_id and channel.category_id in untracked:
continue
for member in channel.members:
if member.bot:
continue
voice_members[(guild.id, member.id)] = TrackedVoiceState.from_voice_state(member.voice)
voice_guilds.add(guild.id)
@@ -299,7 +307,8 @@ class VoiceTrackerCog(LionCog):
# Fetch tracked member session state
session = self.get_session(member.guild.id, member.id)
tstate = session.state
untracked = self.untracked_channels.get(member.guild.id, [])
# This usually pulls from cache, but don't rely on it
untracked = (await self.settings.UntrackedChannels.get(member.guild.id)).data
if (bstate.channelid != astate.channelid):
# Leaving/Moving/Joining channels
@@ -464,8 +473,8 @@ class VoiceTrackerCog(LionCog):
return
async with self.tracking_lock:
sessions = VoiceSession._active_sessions_.get(guildid)
for session in sessions:
sessions = VoiceSession._active_sessions_.get(guildid, {})
for session in list(sessions.values()):
hourly_rate = await self._calculate_rate(session.guildid, session.userid, session.state)
await session.update(new_rate=hourly_rate)

View File

@@ -25,6 +25,8 @@ multiselect_regex = re.compile(
tick = ''
cross = ''
MISSING = object()
class MessageArgs:
"""
@@ -113,7 +115,13 @@ class MessageArgs:
@property
def send_args(self) -> dict:
return self.kwargs
if self.kwargs.get('view', MISSING) is None:
kwargs = self.kwargs.copy()
kwargs.pop('view')
else:
kwargs = self.kwargs
return kwargs
@property
def edit_args(self) -> dict:
@@ -804,3 +812,20 @@ def recurse_map(func, obj, loc=[]):
else:
obj = func(loc, obj)
return obj
async def check_dm(user: discord.User | discord.Member) -> bool:
"""
Check whether we can direct message the given user.
Assumes the client is initialised.
This uses an always-failing HTTP request,
so we need to be very very very careful that this is not used frequently.
Optimally only at the explicit behest of the user
(i.e. during a user instigated interaction).
"""
try:
await user.send('')
except discord.Forbidden:
return False
except discord.HTTPException:
return True

View File

@@ -67,9 +67,9 @@ class Bucket:
def request(self):
self._leak()
if self._level + 1 > self.max_level + 1:
if self._level > self.max_level:
raise BucketOverFull
elif self._level + 1 > self.max_level:
elif self._level == self.max_level:
self._level += 1
if self._last_full:
raise BucketOverFull

View File

@@ -40,7 +40,10 @@ class DurationTransformer(Transformer):
duration = parse_duration(value)
if duration is None:
raise UserInputError(
t(_p('utils:parse_dur|error', "Cannot parse `{value}` as a duration.")).format(
t(_p(
'utils:parse_dur|error',
"Cannot parse `{value}` as a duration."
)).format(
value=value
)
)

View File

@@ -10,6 +10,8 @@ from discord.ui import Modal, View, Item
from meta.logger import log_action_stack, logging_context
from meta.errors import SafeCancellation
from gui.errors import RenderingException
from . import logger
from ..lib import MessageArgs, error_embed
@@ -48,6 +50,16 @@ class LeoUI(View):
# TODO: Replace this with a substitutable ViewLayout class
self._layout: Optional[tuple[tuple[Item, ...], ...]] = None
@property
def _stopped(self) -> asyncio.Future:
"""
Return an future indicating whether the View has finished interacting.
Currently exposes a hidden attribute of the underlying View.
May be reimplemented in future.
"""
return self._View__stopped
def to_components(self) -> List[Dict[str, Any]]:
"""
Extending component generator to apply the set _layout, if it exists.
@@ -218,17 +230,29 @@ class LeoUI(View):
f"Caught a safe cancellation from LeoUI: {e.details}",
extra={'action': 'Cancel'}
)
except RenderingException as e:
logger.info(
f"UI interaction failed due to rendering exception: {repr(e)}"
)
embed = interaction.client.tree.rendersplat(e)
await interaction.client.tree.error_reply(interaction, embed)
except Exception:
logger.exception(
f"Unhandled interaction exception occurred in item {item!r} of LeoUI {self!r}",
f"Unhandled interaction exception occurred in item {item!r} of LeoUI {self!r} from interaction: "
f"{interaction.data}",
extra={'with_ctx': True, 'action': 'UIError'}
)
# Explicitly handle the bugsplat ourselves
splat = interaction.client.tree.bugsplat(interaction, error)
await interaction.client.tree.error_reply(interaction, splat)
class MessageUI(LeoUI):
"""
Simple single-message LeoUI, intended as a framework for UIs
attached to a single interaction response.
UIs may also be sent as regular messages by using `send(channel)` instead of `run(interaction)`.
"""
def __init__(self, *args, callerid: Optional[int] = None, **kwargs):
@@ -396,8 +420,11 @@ class MessageUI(LeoUI):
try:
await self._redraw(args)
except discord.HTTPException:
# Unknown communication erorr, nothing we can reliably do. Exit quietly.
except discord.HTTPException as e:
# Unknown communication error, nothing we can reliably do. Exit quietly.
logger.warning(
f"Unexpected UI redraw failure occurred in {self}: {repr(e)}",
)
await self.close()
async def cleanup(self):
@@ -449,11 +476,20 @@ class LeoModal(Modal):
"""
try:
raise error
except RenderingException as e:
logger.info(
f"Modal submit failed due to rendering exception: {repr(e)}"
)
embed = interaction.client.tree.rendersplat(e)
await interaction.client.tree.error_reply(interaction, embed)
except Exception:
logger.exception(
f"Unhandled interaction exception occurred in {self!r}",
f"Unhandled interaction exception occurred in {self!r}. Interaction: {interaction.data}",
extra={'with_ctx': True, 'action': 'ModalError'}
)
# Explicitly handle the bugsplat ourselves
splat = interaction.client.tree.bugsplat(interaction, error)
await interaction.client.tree.error_reply(interaction, splat)
def error_handler_for(exc):

View File

@@ -21,14 +21,18 @@ async def sys_admin(bot: LionBot, userid: int):
return userid in admins
async def high_management(bot: LionBot, member: discord.Member):
async def high_management(bot: LionBot, member: discord.Member, guild: discord.Guild):
if not guild:
return True
if await sys_admin(bot, member.id):
return True
return member.guild_permissions.administrator
async def low_management(bot: LionBot, member: discord.Member):
if await high_management(bot, member):
async def low_management(bot: LionBot, member: discord.Member, guild: discord.Guild):
if not guild:
return True
if await high_management(bot, member, guild):
return True
return member.guild_permissions.manage_guild
@@ -42,20 +46,20 @@ async def sys_admin_iward(interaction: discord.Interaction) -> bool:
async def high_management_iward(interaction: discord.Interaction) -> bool:
if not interaction.guild:
return False
return await high_management(interaction.client, interaction.user)
return await high_management(interaction.client, interaction.user, interaction.guild)
async def low_management_iward(interaction: discord.Interaction) -> bool:
if not interaction.guild:
return False
return await low_management(interaction.client, interaction.user)
return await low_management(interaction.client, interaction.user, interaction.guild)
# High level ctx wards
async def moderator_ctxward(ctx: LionContext) -> bool:
if not ctx.guild:
return False
passed = await low_management(ctx.bot, ctx.author)
passed = await low_management(ctx.bot, ctx.author, ctx.guild)
if passed:
return True
modrole = ctx.lguild.data.mod_role
@@ -85,7 +89,7 @@ async def sys_admin_ward(ctx: LionContext) -> bool:
async def high_management_ward(ctx: LionContext) -> bool:
if not ctx.guild:
return False
passed = await high_management(ctx.bot, ctx.author)
passed = await high_management(ctx.bot, ctx.author, ctx.guild)
if passed:
return True
else:
@@ -101,7 +105,7 @@ async def high_management_ward(ctx: LionContext) -> bool:
async def low_management_ward(ctx: LionContext) -> bool:
if not ctx.guild:
return False
passed = await low_management(ctx.bot, ctx.author)
passed = await low_management(ctx.bot, ctx.author, ctx.guild)
if passed:
return True
else:
@@ -192,7 +196,7 @@ async def equippable_role(bot: LionBot, target_role: discord.Role, actor: discor
"You need the `MANAGE_ROLES` permission before you can configure roles!"
)).format(role=target_role.mention)
)
elif actor.top_role <= target_role and not actor == guild.owner:
elif actor.top_role <= target_role and not actor.id == guild.owner_id:
raise UserInputError(
t(_p(
'ward:equippable_role|error:actor_top_role',