Merge branch 'release' into pillow

This commit is contained in:
2023-10-15 17:57:56 +03:00
103 changed files with 4724 additions and 1677 deletions

View File

@@ -24,9 +24,9 @@ for name in conf.config.options('LOGGING_LEVELS', no_defaults=True):
class AnalyticsServer:
# TODO: Move these to the config
# How often to request snapshots
snap_period = 120
snap_period = 900
# How soon after a snapshot failure (e.g. not all shards online) to retry
snap_retry_period = 10
snap_retry_period = 60
def __init__(self) -> None:
self.db = Database(conf.data['args'])

View File

@@ -241,7 +241,7 @@ class BabelCog(LionCog):
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)
appcmds.Choice(name=localestr[:100], value=locale)
for locale, localestr in matching
]
else:
@@ -250,7 +250,7 @@ class BabelCog(LionCog):
name=t(_p(
'acmpl:language|no_match',
"No supported languages matching {partial}"
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
]

View File

@@ -1,9 +1,11 @@
import gettext
from typing import Optional
import logging
from contextvars import ContextVar
from collections import defaultdict
from enum import Enum
import gettext
from discord.app_commands import Translator, locale_str
from discord.enums import Locale
@@ -70,7 +72,8 @@ class LeoBabel(Translator):
async def unload(self):
self.translators.clear()
def get_translator(self, locale, domain):
def get_translator(self, locale: Optional[str], domain):
locale = locale or SOURCE_LOCALE
locale = locale.replace('-', '_') if locale else None
if locale == SOURCE_LOCALE:
translator = null

View File

@@ -1,6 +1,3 @@
from .cog import CoreCog
from .config import ConfigCog
from babel.translator import LocalBabel
@@ -8,5 +5,8 @@ babel = LocalBabel('lion-core')
async def setup(bot):
from .cog import CoreCog
from .config import ConfigCog
await bot.add_cog(CoreCog(bot))
await bot.add_cog(ConfigCog(bot))

View File

@@ -1,5 +1,6 @@
from typing import Optional
from collections import defaultdict
from weakref import WeakValueDictionary
import discord
import discord.app_commands as appcmd
@@ -16,6 +17,7 @@ from .lion import Lions
from .lion_guild import GuildConfig
from .lion_member import MemberConfig
from .lion_user import UserConfig
from .hooks import HookedChannel
class keydefaultdict(defaultdict):
@@ -54,6 +56,7 @@ class CoreCog(LionCog):
self.app_cmd_cache: list[discord.app_commands.AppCommand] = []
self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {}
self.mention_cache: dict[str, str] = keydefaultdict(self.mention_cmd)
self.hook_cache: WeakValueDictionary[int, HookedChannel] = WeakValueDictionary()
async def cog_load(self):
# Fetch (and possibly create) core data rows.
@@ -91,7 +94,7 @@ class CoreCog(LionCog):
cache |= subcache
return cache
def mention_cmd(self, name):
def mention_cmd(self, name: str):
"""
Create an application command mention for the given names.
@@ -103,6 +106,12 @@ class CoreCog(LionCog):
mention = f"</{name}:1110834049204891730>"
return mention
def hooked_channel(self, channelid: int):
if (hooked := self.hook_cache.get(channelid, None)) is None:
hooked = HookedChannel(self.bot, channelid)
self.hook_cache[channelid] = hooked
return hooked
async def cog_unload(self):
await self.bot.remove_cog(self.lions.qualified_name)
self.bot.remove_listener(self.shard_update_guilds, name='on_guild_join')

View File

@@ -373,3 +373,6 @@ class CoreData(Registry, name="core"):
webhook = discord.Webhook.partial(self.webhookid, self.token, **kwargs)
webhook.proxy = conf.bot.get('proxy', None)
return webhook
workouts = Table('workout_sessions')
topgg = Table('topgg')

106
src/core/hooks.py Normal file
View File

@@ -0,0 +1,106 @@
from typing import Optional
import logging
import asyncio
import discord
from meta import LionBot
from .data import CoreData
logger = logging.getLogger(__name__)
MISSING = discord.utils.MISSING
class HookedChannel:
def __init__(self, bot: LionBot, channelid: int):
self.bot = bot
self.channelid = channelid
self.webhook: Optional[discord.Webhook] | MISSING = None
self.data: Optional[CoreData.LionHook] = None
self.lock = asyncio.Lock()
@property
def channel(self) -> Optional[discord.TextChannel | discord.VoiceChannel | discord.StageChannel]:
if not self.bot.is_ready():
raise ValueError("Cannot get hooked channel before ready.")
channel = self.bot.get_channel(self.channelid)
if channel and not isinstance(channel, (discord.TextChannel, discord.VoiceChannel, discord.StageChannel)):
raise ValueError(f"Hooked channel expects GuildChannel not '{channel.__class__.__name__}'")
return channel
async def get_webhook(self) -> Optional[discord.Webhook]:
"""
Fetch the saved discord.Webhook for this channel.
Uses cached webhook if possible, but instantiates if required.
Does not create a new webhook, use `create_webhook` for that.
"""
async with self.lock:
if self.webhook is MISSING:
hook = None
elif self.webhook is None:
# Fetch webhook data
data = await CoreData.LionHook.fetch(self.channelid)
if data is not None:
# Instantiate Webhook
hook = self.webhook = data.as_webhook(client=self.bot)
else:
self.webhook = MISSING
hook = None
else:
hook = self.webhook
return hook
async def create_webhook(self, **creation_kwargs) -> Optional[discord.Webhook]:
"""
Create and save a new webhook in this channel.
Returns None if we could not create a new webhook.
"""
async with self.lock:
if self.webhook is not MISSING:
# Delete any existing webhook
if self.webhook is not None:
try:
await self.webhook.delete()
except discord.HTTPException as e:
logger.info(
f"Ignoring exception while refreshing webhook for {self.channelid}: {repr(e)}"
)
await self.bot.core.data.LionHook.table.delete_where(channelid=self.channelid)
self.webhook = MISSING
self.data = None
channel = self.channel
if channel is not None and channel.permissions_for(channel.guild.me).manage_webhooks:
if 'avatar' not in creation_kwargs:
avatar = self.bot.user.avatar if self.bot.user else None
creation_kwargs['avatar'] = (await avatar.to_file()).fp.read() if avatar else None
webhook = await channel.create_webhook(**creation_kwargs)
self.data = await self.bot.core.data.LionHook.create(
channelid=self.channelid,
token=webhook.token,
webhookid=webhook.id,
)
self.webhook = webhook
return webhook
async def invalidate(self, webhook: discord.Webhook):
"""
Invalidate the given webhook.
To be used when the webhook has been deleted on the Discord side.
"""
async with self.lock:
if self.webhook is not None and self.webhook is not MISSING and self.webhook.id == webhook.id:
# Webhook provided matches current webhook
# Delete current webhook
self.webhook = MISSING
self.data = None
await self.bot.core.data.LionHook.table.delete_where(webhookid=webhook.id)

View File

@@ -150,7 +150,10 @@ class Lions(LionCog):
if (lmember := self.lion_members.get(key, None)) is None:
lguild = await self.fetch_guild(guildid, member.guild if member is not None else None)
luser = await self.fetch_user(userid, member)
data = await self.data.Member.fetch_or_create(guildid, userid)
data = await self.data.Member.fetch_or_create(
guildid, userid,
coins=lguild.config.get('starting_funds').value
)
lmember = LionMember(self.bot, data, lguild, luser, member)
self.lion_members[key] = lmember
return lmember
@@ -182,8 +185,8 @@ class Lions(LionCog):
# Create any member rows that are still missing
if missing:
new_rows = await self.data.Member.table.insert_many(
('guildid', 'userid'),
*missing
('guildid', 'userid', 'coins'),
*((gid, uid, lguilds[gid].config.get('starting_funds').value) for gid, uid in missing)
).with_adapter(self.data.Member._make_rows)
rows = itertools.chain(rows, new_rows)

View File

@@ -1,20 +1,85 @@
from typing import Optional, TYPE_CHECKING
from enum import Enum
import asyncio
import datetime as dt
import pytz
import discord
import logging
from meta import LionBot
from utils.lib import Timezoned
from meta import LionBot, conf
from meta.logger import log_wrap
from utils.lib import Timezoned, utc_now
from settings.groups import ModelConfig, SettingDotDict
from babel.translator import ctx_locale
from .hooks import HookedChannel
from .data import CoreData
from . import babel
if TYPE_CHECKING:
# TODO: Import Settings for Config type hinting
pass
_p = babel._p
logger = logging.getLogger(__name__)
event_fields = {
'start': (
_p('eventlog|field:start|name', "Start"),
"{value}",
True,
),
'expiry': (
_p('eventlog|field:expiry|name', "Expires"),
"{value}",
True,
),
'roles_given' : (
_p('eventlog|field:roles_given|name', "Roles Given"),
"{value}",
True,
),
'roles_taken' : (
_p('eventlog|field:roles_given|name', "Roles Taken"),
"{value}",
True,
),
'coins_earned' : (
_p('eventlog|field:coins_earned|name', "Coins Earned"),
"{coin} {{value}}".format(coin=conf.emojis.coin),
True,
),
'price' : (
_p('eventlog|field:price|name', "Price"),
"{coin} {{value}}".format(coin=conf.emojis.coin),
True,
),
'balance' : (
_p('eventlog|field:balance|name', "Balance"),
"{coin} {{value}}".format(coin=conf.emojis.coin),
True,
),
'refund' : (
_p('eventlog|field:refund|name', "Coins Refunded"),
"{coin} {{value}}".format(coin=conf.emojis.coin),
True,
),
'memberid': (
_p('eventlog|field:memberid|name', "Member"),
"<@{value}>",
True,
),
'channelid': (
_p('eventlog|field:channelid|name', "Channel"),
"<#{value}>",
True
),
}
class VoiceMode(Enum):
STUDY = 0
VOICE = 1
@@ -49,7 +114,16 @@ class LionGuild(Timezoned):
No guarantee is made that the client is in the corresponding Guild,
or that the corresponding Guild even exists.
"""
__slots__ = ('bot', 'data', 'guildid', 'config', '_guild', 'voice_lock', '__weakref__')
__slots__ = (
'bot', 'data',
'guildid',
'config',
'_guild',
'voice_lock',
'_eventlogger',
'_tasks',
'__weakref__'
)
Config = GuildConfig
settings = Config.settings
@@ -68,6 +142,24 @@ class LionGuild(Timezoned):
# Avoids voice race-states
self.voice_lock = asyncio.Lock()
# HookedChannel managing the webhook used to send guild event logs
# May be None if no event log is set or if the channel does not exist
self._eventlogger: Optional[HookedChannel] = None
# Set of background tasks associated with this guild (e.g. event logs)
# In theory we should ensure these are finished before the lguild is gcd
# But this is *probably* not an actual problem in practice
self._tasks = set()
@property
def eventlogger(self) -> Optional[HookedChannel]:
channelid = self.data.event_log_channel
if channelid is None:
self._eventlogger = None
elif self._eventlogger is None or self._eventlogger.channelid != channelid:
self._eventlogger = self.bot.core.hooked_channel(channelid)
return self._eventlogger
@property
def guild(self):
if self._guild is None:
@@ -80,7 +172,7 @@ class LionGuild(Timezoned):
return GuildMode.StudyGuild
@property
def timezone(self) -> pytz.timezone:
def timezone(self) -> str:
return self.config.timezone.value
@property
@@ -93,3 +185,168 @@ class LionGuild(Timezoned):
"""
if self.data.name != guild.name:
await self.data.update(name=guild.name)
@log_wrap(action='get event hook')
async def get_event_hook(self) -> Optional[discord.Webhook]:
hooked = self.eventlogger
ctx_locale.set(self.locale)
if hooked:
hook = await hooked.get_webhook()
if hook is not None:
pass
elif (channel := hooked.channel) is None:
# Event log channel doesn't exist
pass
elif not channel.permissions_for(channel.guild.me).manage_webhooks:
# Cannot create a webhook here
if channel.permissions_for(channel.guild.me).send_messages:
t = self.bot.translator.t
try:
await channel.send(t(_p(
'eventlog|error:manage_webhooks',
"This channel is configured as an event log, "
"but I am missing the 'Manage Webhooks' permission here."
)))
except discord.HTTPException:
pass
else:
# We should be able to create the hook
t = self.bot.translator.t
try:
hook = await hooked.create_webhook(
name=t(_p(
'eventlog|create|name',
"{bot_name} Event Log"
)).format(bot_name=channel.guild.me.name),
reason=t(_p(
'eventlog|create|audit_reason',
"Creating event log webhook"
)),
)
except discord.HTTPException:
logger.warning(
f"Unexpected exception while creating event log webhook for <gid: {self.guildid}>",
exc_info=True
)
return hook
@log_wrap(action="Log Event")
async def _log_event(self, embed: discord.Embed, retry=True):
logger.debug(f"Logging event log event: {embed.to_dict()}")
hook = await self.get_event_hook()
if hook is not None:
try:
await hook.send(embed=embed)
except discord.NotFound:
logger.info(
f"Event log in <gid: {self.guildid}> invalidated. Recreating: {retry}"
)
hooked = self.eventlogger
if hooked is not None:
await hooked.invalidate(hook)
if retry:
await self._log_event(embed, retry=False)
except discord.HTTPException:
logger.warning(
f"Discord exception occurred sending event log event: {embed.to_dict()}.",
exc_info=True
)
except Exception:
logger.exception(
f"Unknown exception occurred sending event log event: {embed.to_dict()}."
)
def log_event(self,
title: Optional[str]=None, description: Optional[str]=None,
timestamp: Optional[dt.datetime]=None,
*,
embed: Optional[discord.Embed] = None,
fields: dict[str, tuple[str, bool]]={},
errors: list[str]=[],
**kwargs: str | int):
"""
Synchronously log an event to the guild event log.
Does nothing if the event log has not been set up.
Parameters
----------
title: str
Embed title
description: str
Embed description
timestamp: dt.datetime
Embed timestamp. Defaults to `now` if not given.
embed: discord.Embed
Optional base embed to use.
May be used to completely customise log message.
fields: dict[str, tuple[str, bool]]
Optional embed fields to add.
errors: list[str]
Optional list of errors to add.
Errors will always be added last.
kwargs: str | int
Optional embed fields to add to the embed.
These differ from `fields` in that the kwargs keys will be automatically matched and localised
if possible.
These will be added before the `fields` given.
"""
t = self.bot.translator.t
# Build embed
if embed is not None:
base = embed
else:
base = discord.Embed(
colour=(discord.Colour.brand_red() if errors else discord.Colour.dark_orange())
)
if description is not None:
base.description = description
if title is not None:
base.title = title
if timestamp is not None:
base.timestamp = timestamp
else:
base.timestamp = utc_now()
# Add embed fields
for key, value in kwargs.items():
if value is None:
continue
if key in event_fields:
_field_name, _field_value, inline = event_fields[key]
field_name = t(_field_name, locale=self.locale)
field_value = _field_value.format(value=value)
else:
field_name = key
field_value = value
inline = False
base.add_field(
name=field_name,
value=field_value,
inline=inline
)
for key, (value, inline) in fields.items():
base.add_field(
name=key,
value=value,
inline=inline,
)
if errors:
error_name = t(_p(
'eventlog|field:errors|name',
"Errors"
))
error_value = '\n'.join(f"- {line}" for line in errors)
base.add_field(
name=error_name, value=error_value, inline=False
)
# Send embed
task = asyncio.create_task(self._log_event(embed=base), name='event-log')
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)

View File

@@ -2,6 +2,7 @@ from typing import Optional
import datetime as dt
import pytz
import discord
import logging
from meta import LionBot
from utils.lib import Timezoned
@@ -13,6 +14,9 @@ from .lion_user import LionUser
from .lion_guild import LionGuild
logger = logging.getLogger(__name__)
class MemberConfig(ModelConfig):
settings = SettingDotDict()
_model_settings = set()
@@ -103,14 +107,30 @@ class LionMember(Timezoned):
async def remove_role(self, role: discord.Role):
member = await self.fetch_member()
if member is not None and role in member.roles:
if member is not None:
try:
await member.remove_roles(role)
except discord.HTTPException:
except discord.HTTPException as e:
# TODO: Logging, audit logging
pass
logger.warning(
"Lion role removal failed for "
f"<uid: {member.id}>, <gid: {member.guild.id}>, <rid: {role.id}>. "
f"Error: {repr(e)}",
)
else:
if role not in member.roles:
logger.info(
f"Removed role <rid: {role.id}> from member <uid: {self.userid}> in <gid: {self.guildid}>"
)
else:
logger.error(
f"Tried to remove role <rid: {role.id}> "
f"from member <uid: {self.userid}> in <gid: {self.guildid}>. "
"Role remove succeeded, but member still has the role."
)
else:
# Remove the role from persistent role storage
cog = self.bot.get_cog('MemberAdminCog')
if cog:
await cog.absent_remove_role(self.guildid, self.userid, role.id)
logger.info(f"Removed role <rid: {role.id}> from absent lion <uid: {self.userid}> in <gid: {self.guildid}>")

Submodule src/gui updated: 24e94d10e2...f2760218ef

View File

@@ -46,7 +46,7 @@ class LionBot(Bot):
# self.appdata = appdata
self.config = config
self.app_ipc = app_ipc
self.core: Optional['CoreCog'] = None
self.core: 'CoreCog' = None
self.translator = translator
self.system_monitor = SystemMonitor()

View File

@@ -38,8 +38,9 @@ class LionTree(CommandTree):
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)
if interaction.type is not InteractionType.autocomplete:
embed = self.bugsplat(interaction, error)
await self.error_reply(interaction, embed)
async def error_reply(self, interaction, embed):
if not interaction.is_expired():
@@ -144,7 +145,10 @@ class LionTree(CommandTree):
raise AppCommandError(
'This should not happen, but there is no focused element. This is a Discord bug.'
)
await command._invoke_autocomplete(interaction, focused, namespace)
try:
await command._invoke_autocomplete(interaction, focused, namespace)
except Exception as e:
await self.on_error(interaction, e)
return
set_logging_context(action=f"Run {command.qualified_name}")

View File

@@ -6,8 +6,6 @@ babel = LocalBabel('config')
async def setup(bot):
from .general import GeneralSettingsCog
from .cog import DashCog
from .cog import GuildConfigCog
await bot.add_cog(GeneralSettingsCog(bot))
await bot.add_cog(DashCog(bot))
await bot.add_cog(GuildConfigCog(bot))

View File

@@ -1,24 +1,35 @@
from typing import Optional
import discord
from discord import app_commands as appcmds
from discord.ext import commands as cmds
from meta import LionBot, LionContext, LionCog
from wards import low_management_ward
from . import babel
from .dashboard import GuildDashboard
from .settings import GeneralSettings
from .settingui import GeneralSettingUI
_p = babel._p
class DashCog(LionCog):
class GuildConfigCog(LionCog):
depends = {'CoreCog'}
def __init__(self, bot: LionBot):
self.bot = bot
self.settings = GeneralSettings()
async def cog_load(self):
...
self.bot.core.guild_config.register_model_setting(GeneralSettings.Timezone)
self.bot.core.guild_config.register_model_setting(GeneralSettings.EventLog)
async def cog_unload(self):
...
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
raise ValueError("Cannot load GuildConfigCog without ConfigCog")
self.crossload_group(self.configure_group, configcog.configure_group)
@cmds.hybrid_command(
name="dashboard",
@@ -27,6 +38,75 @@ class DashCog(LionCog):
@appcmds.guild_only
@appcmds.default_permissions(manage_guild=True)
async def dashboard_cmd(self, ctx: LionContext):
if not ctx.guild or not ctx.interaction:
return
ui = GuildDashboard(self.bot, ctx.guild, ctx.author.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()
# ----- Configuration -----
@LionCog.placeholder_group
@cmds.hybrid_group("configure", with_app_command=False)
async def configure_group(self, ctx: LionContext):
# Placeholder configure group command.
...
@configure_group.command(
name=_p('cmd:configure_general', "general"),
description=_p('cmd:configure_general|desc', "General configuration panel")
)
@appcmds.rename(
timezone=GeneralSettings.Timezone._display_name,
event_log=GeneralSettings.EventLog._display_name,
)
@appcmds.describe(
timezone=GeneralSettings.Timezone._desc,
event_log=GeneralSettings.EventLog._desc,
)
@appcmds.guild_only()
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def cmd_configure_general(self, ctx: LionContext,
timezone: Optional[str] = None,
event_log: Optional[discord.TextChannel] = None,
):
t = self.bot.translator.t
# Typechecker guards because they don't understand the check ward
if not ctx.guild:
return
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if timezone is not None:
setting = self.settings.Timezone
instance = await setting.from_string(ctx.guild.id, timezone)
modified.append(instance)
if event_log is not None:
setting = self.settings.EventLog
instance = await setting.from_value(ctx.guild.id, event_log)
modified.append(instance)
if modified:
ack_lines = []
for instance in modified:
await instance.write()
ack_lines.append(instance.update_message)
tick = self.bot.config.emojis.tick
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description='\n'.join(f"{tick} {line}" for line in ack_lines)
)
await ctx.reply(embed=embed)
if ctx.channel.id not in GeneralSettingUI._listening or not modified:
ui = GeneralSettingUI(self.bot, ctx.guild.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

View File

@@ -22,6 +22,7 @@ from modules.statistics.settings import StatisticsDashboard
from modules.member_admin.settingui import MemberAdminDashboard
from modules.moderation.settingui import ModerationDashboard
from modules.video_channels.settingui import VideoDashboard
from modules.config.settingui import GeneralDashboard
from . import babel, logger
@@ -35,7 +36,7 @@ class GuildDashboard(BasePager):
Paged UI providing an overview of the guild configuration.
"""
pages = [
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard, GeneralDashboard,),
(ModerationDashboard, VideoDashboard,),
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
(TasklistDashboard, RoomDashboard, TimerDashboard,),
@@ -185,23 +186,28 @@ class GuildDashboard(BasePager):
# ----- UI Control -----
async def reload(self, *args):
self._cached_pages.clear()
if not self._original.is_expired():
if self._original and not self._original.is_expired():
await self.redraw()
else:
await self.close()
async def refresh(self):
await super().refresh()
await self.config_menu_refresh()
self._layout = [
self.set_layout(
(self.config_menu,),
(self.prev_page_button, self.next_page_button)
]
)
async def redraw(self, *args):
await self.refresh()
await self._original.edit_original_response(
**self.current_page.edit_args,
view=self
)
if self._original and not self._original.is_expired():
await self._original.edit_original_response(
**self.current_page.edit_args,
view=self
)
else:
await self.close()
async def run(self, interaction: discord.Interaction):
await self.refresh()

View File

@@ -26,48 +26,6 @@ from . import babel
_p = babel._p
class GeneralSettings(SettingGroup):
class Timezone(ModelData, TimezoneSetting):
"""
Guild timezone configuration.
Exposed via `/configure general timezone:`, and the standard interface.
The `timezone` setting acts as the default timezone for all members,
and the timezone used to display guild-wide statistics.
"""
setting_id = 'timezone'
_event = 'guild_setting_update_timezone'
_display_name = _p('guildset:timezone', "timezone")
_desc = _p(
'guildset:timezone|desc',
"Guild timezone for statistics display."
)
_long_desc = _p(
'guildset:timezone|long_desc',
"Guild-wide timezone. "
"Used to determine start of the day for the leaderboards, "
"and as the default statistics timezone for members who have not set one."
)
_default = 'UTC'
_model = CoreData.Guild
_column = CoreData.Guild.timezone.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:timezone|response',
"The guild timezone has been set to `{timezone}`."
)).format(timezone=self.data)
@property
def set_str(self):
bot = ctx_bot.get()
return bot.core.mention_cmd('configure general') if bot else None
class GeneralSettingsCog(LionCog):
depends = {'CoreCog'}
@@ -87,24 +45,28 @@ class GeneralSettingsCog(LionCog):
@LionCog.placeholder_group
@cmds.hybrid_group("configure", with_app_command=False)
async def configure_group(self, ctx: LionContext):
# Placeholder configure group command.
...
# Placeholder configure group command.
...
@configure_group.command(
name=_p('cmd:configure_general', "general"),
description=_p('cmd:configure_general|desc', "General configuration panel")
)
@appcmds.rename(
timezone=GeneralSettings.Timezone._display_name
timezone=GeneralSettings.Timezone._display_name,
event_log=GeneralSettings.EventLog._display_name,
)
@appcmds.describe(
timezone=GeneralSettings.Timezone._desc
timezone=GeneralSettings.Timezone._desc,
event_log=GeneralSettings.EventLog._display_name,
)
@appcmds.guild_only()
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def cmd_configure_general(self, ctx: LionContext,
timezone: Optional[str] = None):
timezone: Optional[str] = None,
event_log: Optional[discord.TextChannel] = None,
):
t = self.bot.translator.t
# Typechecker guards because they don't understand the check ward
@@ -149,25 +111,25 @@ class GeneralSettingsCog(LionCog):
'cmd:configure_general|success',
"Settings Updated!"
)),
description='\n'.join(
f"{self.bot.config.emojis.tick} {line}" for line in results
)
description='\n'.join(
f"{self.bot.config.emojis.tick} {line}" for line in results
)
await ctx.reply(embed=success_embed)
# TODO: Trigger configuration panel update if listening UI.
else:
# Show general configuration panel UI
# TODO Interactive UI
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'cmd:configure_general|panel|title',
"General Configuration Panel"
))
)
embed.add_field(
**ctx.lguild.config.timezone.embed_field
)
await ctx.reply(embed=embed)
)
await ctx.reply(embed=success_embed)
# TODO: Trigger configuration panel update if listening UI.
else:
# Show general configuration panel UI
# TODO Interactive UI
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'cmd:configure_general|panel|title',
"General Configuration Panel"
))
)
embed.add_field(
**ctx.lguild.config.timezone.embed_field
)
await ctx.reply(embed=embed)
cmd_configure_general.autocomplete('timezone')(TimezoneSetting.parse_acmpl)

View File

@@ -0,0 +1,110 @@
from typing import Optional
import discord
from settings import ModelData
from settings.setting_types import TimezoneSetting, ChannelSetting
from settings.groups import SettingGroup
from meta.context import ctx_bot
from meta.errors import UserInputError
from core.data import CoreData
from babel.translator import ctx_translator
from . import babel
_p = babel._p
class GeneralSettings(SettingGroup):
class Timezone(ModelData, TimezoneSetting):
"""
Guild timezone configuration.
Exposed via `/configure general timezone:`, and the standard interface.
The `timezone` setting acts as the default timezone for all members,
and the timezone used to display guild-wide statistics.
"""
setting_id = 'timezone'
_event = 'guildset_timezone'
_set_cmd = 'configure general'
_display_name = _p('guildset:timezone', "timezone")
_desc = _p(
'guildset:timezone|desc',
"Guild timezone for statistics display."
)
_long_desc = _p(
'guildset:timezone|long_desc',
"Guild-wide timezone. "
"Used to determine start of the day for the leaderboards, "
"and as the default statistics timezone for members who have not set one."
)
_default = 'UTC'
_model = CoreData.Guild
_column = CoreData.Guild.timezone.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:timezone|response',
"The guild timezone has been set to `{timezone}`."
)).format(timezone=self.data)
class EventLog(ModelData, ChannelSetting):
"""
Guild event log channel.
"""
setting_id = 'eventlog'
_event = 'guildset_eventlog'
_set_cmd = 'configure general'
_display_name = _p('guildset:eventlog', "event_log")
_desc = _p(
'guildset:eventlog|desc',
"My audit log channel where I send server actions and events (e.g. rankgs and expiring roles)."
)
_long_desc = _p(
'guildset:eventlog|long_desc',
"If configured, I will log most significant actions taken "
"or events which occur through my interface, into this channel. "
"Logged events include, for example:\n"
"- Member voice activity\n"
"- Roles equipped and expiring from rolemenus\n"
"- Privated rooms rented and expiring\n"
"- Activity ranks earned\n"
"I must have the 'Manage Webhooks' permission in this channel."
)
_model = CoreData.Guild
_column = CoreData.Guild.event_log_channel.name
@classmethod
async def _check_value(cls, parent_id: int, value: Optional[discord.abc.GuildChannel], **kwargs):
if value is not None:
t = ctx_translator.get().t
if not value.permissions_for(value.guild.me).manage_webhooks:
raise UserInputError(
t(_p(
'guildset:eventlog|check_value|error:perms|perm:manage_webhooks',
"Cannot set {channel} as an event log! I lack the 'Manage Webhooks' permission there."
)).format(channel=value)
)
@property
def update_message(self):
t = ctx_translator.get().t
channel = self.value
if channel is not None:
response = t(_p(
'guildset:eventlog|response|set',
"Events will now be logged to {channel}"
)).format(channel=channel.mention)
else:
response = t(_p(
'guildset:eventlog|response|unset',
"Guild events will no longer be logged."
))
return response

View File

@@ -0,0 +1,105 @@
import asyncio
import discord
from discord.ui.select import select, ChannelSelect
from meta import LionBot
from meta.errors import UserInputError
from utils.ui import ConfigUI, DashboardSection
from utils.lib import MessageArgs
from . import babel
from .settings import GeneralSettings
_p = babel._p
class GeneralSettingUI(ConfigUI):
setting_classes = (
GeneralSettings.Timezone,
GeneralSettings.EventLog,
)
def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs):
self.settings = bot.get_cog('GuildConfigCog').settings
super().__init__(bot, guildid, channelid, **kwargs)
# ----- UI Components -----
# Event log
@select(
cls=ChannelSelect,
channel_types=[discord.ChannelType.text, discord.ChannelType.voice],
placeholder='EVENT_LOG_PLACEHOLDER',
min_values=0, max_values=1,
)
async def eventlog_menu(self, selection: discord.Interaction, selected: ChannelSelect):
"""
Single channel selector for the event log.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(GeneralSettings.EventLog)
value = selected.values[0].resolve() if selected.values else None
setting = await setting.from_value(self.guildid, value)
await setting.write()
await selection.delete_original_response()
async def eventlog_menu_refresh(self):
menu = self.eventlog_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:general_config|menu:event_log|placeholder',
"Select Event Log"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:general_config|embed:title',
"General Configuration"
))
embed = discord.Embed(
title=title,
colour=discord.Colour.orange()
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)
async def reload(self):
self.instances = [
await setting.get(self.guildid)
for setting in self.setting_classes
]
async def refresh_components(self):
to_refresh = (
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.eventlog_menu_refresh(),
)
await asyncio.gather(*to_refresh)
self.set_layout(
(self.eventlog_menu,),
(self.edit_button, self.reset_button, self.close_button,),
)
class GeneralDashboard(DashboardSection):
section_name = _p(
"dash:general|title",
"General Configuration ({commands[configure general]})"
)
_option_name = _p(
"dash:general|option|name",
"General Configuration Panel"
)
configui = GeneralSettingUI
setting_classes = configui.setting_classes

View File

@@ -56,6 +56,7 @@ class Economy(LionCog):
self.bot.core.guild_config.register_model_setting(self.settings.AllowTransfers)
self.bot.core.guild_config.register_model_setting(self.settings.CoinsPerXP)
self.bot.core.guild_config.register_model_setting(self.settings.StartingFunds)
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
@@ -298,6 +299,20 @@ class Economy(LionCog):
).set(
coins=set_to
)
ctx.lguild.log_event(
title=t(_p(
'eventlog|event:economy_set|title',
"Moderator Set Economy Balance"
)),
description=t(_p(
'eventlog|event:economy_set|desc',
"{moderator} set {target}'s balance to {amount}."
)).format(
moderator=ctx.author.mention,
target=target.mention,
amount=f"{cemoji}**{set_to}**",
)
)
else:
if role:
if role.is_default():
@@ -359,6 +374,20 @@ class Economy(LionCog):
amount=add,
new_amount=results[0]['coins']
)
ctx.lguild.log_event(
title=t(_p(
'eventlog|event:economy_add|title',
"Moderator Modified Economy Balance"
)),
description=t(_p(
'eventlog|event:economy_set|desc',
"{moderator} added {amount} to {target}'s balance."
)).format(
moderator=ctx.author.mention,
target=target.mention,
amount=f"{cemoji}**{add}**",
)
)
title = t(_np(
'cmd:economy_balance|embed:success|title',
@@ -781,7 +810,20 @@ class Economy(LionCog):
await ctx.alion.data.update(coins=(Member.coins - amount))
await target_lion.data.update(coins=(Member.coins + amount))
# TODO: Audit trail
ctx.lguild.log_event(
title=t(_p(
"eventlog|event:send|title",
"Coins Transferred"
)),
description=t(_p(
'eventlog|event:send|desc',
"{source} gifted {amount} to {target}"
)).format(
source=ctx.author.mention,
target=target.mention,
amount=f"{self.bot.config.emojis.coin}**{amount}**"
),
)
await asyncio.create_task(wrapped(), name="wrapped-send")
# Message target
@@ -847,11 +889,13 @@ class Economy(LionCog):
)
@appcmds.rename(
allow_transfers=EconomySettings.AllowTransfers._display_name,
coins_per_xp=EconomySettings.CoinsPerXP._display_name
coins_per_xp=EconomySettings.CoinsPerXP._display_name,
starting_funds=EconomySettings.StartingFunds._display_name,
)
@appcmds.describe(
allow_transfers=EconomySettings.AllowTransfers._desc,
coins_per_xp=EconomySettings.CoinsPerXP._desc
coins_per_xp=EconomySettings.CoinsPerXP._desc,
starting_funds=EconomySettings.StartingFunds._desc,
)
@appcmds.choices(
allow_transfers=[
@@ -863,7 +907,9 @@ class Economy(LionCog):
@moderator_ward
async def configure_economy(self, ctx: LionContext,
allow_transfers: Optional[appcmds.Choice[int]] = None,
coins_per_xp: Optional[appcmds.Range[int, 0, 2**15]] = None):
coins_per_xp: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
starting_funds: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
):
t = self.bot.translator.t
if not ctx.interaction:
return
@@ -872,6 +918,7 @@ class Economy(LionCog):
setting_allow_transfers = ctx.lguild.config.get('allow_transfers')
setting_coins_per_xp = ctx.lguild.config.get('coins_per_xp')
setting_starting_funds = ctx.lguild.config.get('starting_funds')
modified = []
if allow_transfers is not None:
@@ -882,6 +929,10 @@ class Economy(LionCog):
setting_coins_per_xp.data = coins_per_xp
await setting_coins_per_xp.write()
modified.append(setting_coins_per_xp)
if starting_funds is not None:
setting_starting_funds.data = starting_funds
await setting_starting_funds.write()
modified.append(setting_starting_funds)
if modified:
desc = '\n'.join(f"{conf.emojis.tick} {setting.update_message}" for setting in modified)

View File

@@ -15,6 +15,7 @@ from meta.config import conf
from meta.sharding import THIS_SHARD
from meta.logger import log_wrap
from core.data import CoreData
from core.setting_types import CoinSetting
from babel.translator import ctx_translator
from . import babel, logger
@@ -29,7 +30,7 @@ class EconomySettings(SettingGroup):
coins_per_100xp
allow_transfers
"""
class CoinsPerXP(ModelData, IntegerSetting):
class CoinsPerXP(ModelData, CoinSetting):
setting_id = 'coins_per_xp'
_display_name = _p('guildset:coins_per_xp', "coins_per_100xp")
@@ -111,3 +112,32 @@ class EconomySettings(SettingGroup):
coin=conf.emojis.coin
)
return formatted
class StartingFunds(ModelData, CoinSetting):
setting_id = 'starting_funds'
_display_name = _p('guildset:starting_funds', "starting_funds")
_desc = _p(
'guildset:starting_funds|desc',
"How many LionCoins should a member start with."
)
_long_desc = _p(
'guildset:starting_funds|long_desc',
"Members will be given this number of coins when they first interact with me, or first join the server."
)
_accepts = _p(
'guildset:starting_funds|accepts',
"Number of coins to give to new members."
)
_default = 0
_model = CoreData.Guild
_column = CoreData.Guild.starting_funds.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:starting_funds|set_response',
"New members will now start with {amount}"
)).format(amount=self.formatted)

View File

@@ -17,8 +17,9 @@ _p = babel._p
class EconomyConfigUI(ConfigUI):
setting_classes = (
EconomySettings.StartingFunds,
EconomySettings.CoinsPerXP,
EconomySettings.AllowTransfers
EconomySettings.AllowTransfers,
)
def __init__(self, bot: LionBot,
@@ -44,11 +45,9 @@ class EconomyConfigUI(ConfigUI):
async def reload(self):
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
coins_per_xp = lguild.config.get(self.settings.CoinsPerXP.setting_id)
allow_transfers = lguild.config.get(self.settings.AllowTransfers.setting_id)
self.instances = (
coins_per_xp,
allow_transfers
self.instances = tuple(
lguild.config.get(cls.setting_id)
for cls in self.setting_classes
)
async def refresh_components(self):
@@ -57,9 +56,9 @@ class EconomyConfigUI(ConfigUI):
self.close_button_refresh(),
self.reset_button_refresh(),
)
self._layout = [
self.set_layout(
(self.edit_button, self.reset_button, self.close_button),
]
)
class EconomyDashboard(DashboardSection):

View File

@@ -8,6 +8,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 babel.translator import ctx_locale
from utils.lib import utc_now
from wards import low_management_ward, equippable_role, high_management_ward
@@ -109,6 +110,23 @@ class MemberAdminCog(LionCog):
)
finally:
self._adding_roles.discard((member.guild.id, member.id))
t = self.bot.translator.t
ctx_locale.set(lion.lguild.locale)
lion.lguild.log_event(
title=t(_p(
'eventlog|event:welcome|title',
"New Member Joined"
)),
name=t(_p(
'eventlog|event:welcome|desc',
"{member} joined the server for the first time.",
)).format(
member=member.mention
),
roles_given='\n'.join(role.mention for role in roles) if roles else None,
balance=lion.data.coins,
)
else:
# Returning member
@@ -181,6 +199,39 @@ class MemberAdminCog(LionCog):
finally:
self._adding_roles.discard((member.guild.id, member.id))
t = self.bot.translator.t
ctx_locale.set(lion.lguild.locale)
lion.lguild.log_event(
title=t(_p(
'eventlog|event:returning|title',
"Member Rejoined"
)),
name=t(_p(
'eventlog|event:returning|desc',
"{member} rejoined the server.",
)).format(
member=member.mention
),
balance=lion.data.coins,
roles_given='\n'.join(role.mention for role in roles) if roles else None,
fields={
t(_p(
'eventlog|event:returning|field:first_joined',
"First Joined"
)): (
discord.utils.format_dt(lion.data.first_joined) if lion.data.first_joined else 'Unknown',
True
),
t(_p(
'eventlog|event:returning|field:last_seen',
"Last Seen"
)): (
discord.utils.format_dt(lion.data.last_left) if lion.data.last_left else 'Unknown',
True
),
},
)
@LionCog.listener('on_raw_member_remove')
@log_wrap(action="Farewell")
async def admin_member_farewell(self, payload: discord.RawMemberRemoveEvent):
@@ -195,6 +246,7 @@ class MemberAdminCog(LionCog):
await lion.data.update(last_left=utc_now())
# Save member roles
roles = None
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
@@ -206,6 +258,7 @@ class MemberAdminCog(LionCog):
print(type(payload.user))
if isinstance(payload.user, discord.Member) and payload.user.roles:
member = payload.user
roles = member.roles
await self.data.past_roles.insert_many(
('guildid', 'userid', 'roleid'),
*((guildid, userid, role.id) for role in member.roles)
@@ -213,7 +266,38 @@ class MemberAdminCog(LionCog):
logger.debug(
f"Stored persisting roles for member <uid:{userid}> in <gid:{guildid}>."
)
# TODO: Event log, and include info about unchunked members
t = self.bot.translator.t
ctx_locale.set(lion.lguild.locale)
lion.lguild.log_event(
title=t(_p(
'eventlog|event:left|title',
"Member Left"
)),
name=t(_p(
'eventlog|event:left|desc',
"{member} left the server.",
)).format(
member=f"<@{userid}>"
),
balance=lion.data.coins,
fields={
t(_p(
'eventlog|event:left|field:stored_roles',
"Stored Roles"
)): (
'\n'.join(role.mention for role in roles) if roles is not None else 'None',
True
),
t(_p(
'eventlog|event:left|field:first_joined',
"First Joined"
)): (
discord.utils.format_dt(lion.data.first_joined) if lion.data.first_joined else 'Unknown',
True
),
},
)
@LionCog.listener('on_guild_join')
async def admin_init_guild(self, guild: discord.Guild):
@@ -227,7 +311,8 @@ class MemberAdminCog(LionCog):
logger.info(f"Cleared persisting roles for guild <gid:{guild.id}> because we left the guild.")
@LionCog.listener('on_guildset_role_persistence')
async def clear_stored_roles(self, guildid, data):
async def clear_stored_roles(self, guildid, setting: MemberAdminSettings.RolePersistence):
data = setting.data
if data is False:
await self.data.past_roles.delete_where(guildid=guildid)
logger.info(

View File

@@ -1,4 +1,5 @@
import asyncio
import pytz
import datetime as dt
from typing import Optional
@@ -161,7 +162,7 @@ class Ticket:
embed = discord.Embed(
title=title,
description=data.content,
timestamp=data.created_at,
timestamp=data.created_at.replace(tzinfo=pytz.utc),
colour=discord.Colour.orange()
)
embed.add_field(

View File

@@ -73,7 +73,7 @@ class TimerCog(LionCog):
launched=sum(1 for timer in timers if timer._run_task and not timer._run_task.done()),
looping=sum(1 for timer in timers if timer._loop_task and not timer._loop_task.done()),
locked=sum(1 for timer in timers if timer._lock.locked()),
voice_locked=sum(1 for timer in timers if timer._voice_update_lock.locked()),
voice_locked=sum(1 for timer in timers if timer.voice_lock.locked()),
)
if not self.ready:
level = StatusLevel.STARTING
@@ -345,7 +345,7 @@ class TimerCog(LionCog):
@LionCog.listener('on_guildset_pomodoro_channel')
@log_wrap(action='Update Pomodoro Channels')
async def _update_pomodoro_channels(self, guildid: int, data: Optional[int]):
async def _update_pomodoro_channels(self, guildid: int, setting: TimerSettings.PomodoroChannel):
"""
Request a send_status for all guild timers which need to move channel.
"""

View File

@@ -57,7 +57,7 @@ class TimerOptions(SettingGroup):
_allow_object = False
@classmethod
async def _check_value(cls, parent_id: int, value: Optional[discord.abc.GuildChannel], **kwargs):
async def _check_value(cls, parent_id: int, value, **kwargs):
if value is not None:
# TODO: Check we either have or can create a webhook
# TODO: Check we can send messages, embeds, and files

View File

@@ -136,6 +136,10 @@ class Timer:
channel = self.channel
return channel
@property
def voice_lock(self):
return self.lguild.voice_lock
async def get_notification_webhook(self) -> Optional[discord.Webhook]:
channel = self.notification_channel
if channel:
@@ -474,14 +478,13 @@ class Timer:
async with self.lguild.voice_lock:
try:
if self.guild.voice_client:
print("Disconnecting")
await self.guild.voice_client.disconnect(force=True)
print("Disconnected")
alert_file = focus_alert_path if stage.focused else break_alert_path
try:
print("Connecting")
voice_client = await self.channel.connect(timeout=60, reconnect=False)
print("Connected")
voice_client = await asyncio.wait_for(
self.channel.connect(timeout=30, reconnect=False),
timeout=60
)
except asyncio.TimeoutError:
logger.warning(f"Timed out while connecting to voice channel in timer {self!r}")
return
@@ -508,13 +511,18 @@ class Timer:
_, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
if self.guild and self.guild.voice_client:
await self.guild.voice_client.disconnect(force=True)
except asyncio.TimeoutError:
logger.warning(
f"Timed out while sending voice alert for timer {self!r}",
exc_info=True
)
except Exception:
logger.exception(
f"Exception occurred while playing voice alert for timer {self!r}"
)
finally:
if self.guild and self.guild.voice_client:
await self.guild.voice_client.disconnect(force=True)
def stageline(self, stage: Stage):
t = self.bot.translator.t
@@ -777,7 +785,7 @@ class Timer:
logger.info(f"Timer {self!r} has stopped. Auto restart is {'on' if auto_restart else 'off'}")
@log_wrap(action="Destroy Timer")
async def destroy(self, reason: str = None):
async def destroy(self, reason: Optional[str] = None):
"""
Deconstructs the timer, stopping all tasks.
"""

View File

@@ -145,13 +145,11 @@ class TimerOptionsUI(MessageUI):
value = selected.values[0] if selected.values else None
setting = self.timer.config.get('notification_channel')
if issue := await setting._check_value(self.timer.data.channelid, value):
await selection.edit_original_response(embed=error_embed(issue))
else:
setting.value = value
await setting.write()
await self.timer.send_status()
await self.refresh(thinking=selection)
await setting._check_value(self.timer.data.channelid, value)
setting.value = value
await setting.write()
await self.timer.send_status()
await self.refresh(thinking=selection)
async def refresh_notification_menu(self):
self.notification_menu.placeholder = self.bot.translator.t(_p(

View File

@@ -319,10 +319,15 @@ class RankCog(LionCog):
if roleid in rank_roleids and roleid != current_roleid
]
t = self.bot.translator.t
log_errors: list[str] = []
log_added = None
log_removed = None
# Now update roles
new_last_roleid = last_roleid
# TODO: Event log here, including errors
# TODO: Factor out role updates
to_rm = [role for role in to_rm if role.is_assignable()]
if to_rm:
try:
@@ -336,32 +341,68 @@ class RankCog(LionCog):
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
)
new_last_roleid = None
except discord.HTTPException:
except discord.HTTPException as e:
logger.warning(
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
exc_info=True
)
log_errors.append(t(_p(
'eventlog|event:rank_check|error:remove_failed',
"Failed to remove old rank roles: `{error}`"
)).format(error=str(e)))
log_removed = '\n'.join(role.mention for role in to_rm)
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 to_add:
if 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 as e:
logger.warning(
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> "
f"their rank role <rid:{to_add.id}>",
exc_info=True
)
log_errors.append(t(_p(
'eventlog|event:rank_check|error:add_failed',
"Failed to add new rank role: `{error}`"
)).format(error=str(e)))
else:
log_errors.append(t(_p(
'eventlog|event:rank_check|error:add_impossible',
"Could not assign new activity rank role. Lacking permissions or invalid role."
)))
log_added = to_add.mention
else:
log_errors.append(t(_p(
'eventlog|event:rank_check|error:permissions',
"Could not update activity rank roles, I lack the 'Manage Roles' permission."
)))
if new_last_roleid != last_roleid:
await session_rank.rankrow.update(last_roleid=new_last_roleid)
if to_add or to_rm:
# Log rank role update
lguild = await self.bot.core.lions.fetch_guild(guildid)
lguild.log_event(
t(_p(
'eventlog|event:rank_check|name',
"Member Activity Rank Roles Updated"
)),
memberid=member.id,
roles_given=log_added,
roles_taken=log_removed,
errors=log_errors,
)
@log_wrap(action="Update Rank")
async def update_rank(self, session_rank):
# Identify target rank
@@ -390,6 +431,11 @@ class RankCog(LionCog):
if member is None:
return
t = self.bot.translator.t
log_errors: list[str] = []
log_added = None
log_removed = None
last_roleid = session_rank.rankrow.last_roleid
# Update ranks
@@ -409,7 +455,6 @@ class RankCog(LionCog):
]
# 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:
@@ -423,28 +468,50 @@ class RankCog(LionCog):
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
)
last_roleid = None
except discord.HTTPException:
except discord.HTTPException as e:
logger.warning(
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
exc_info=True
)
log_errors.append(t(_p(
'eventlog|event:new_rank|error:remove_failed',
"Failed to remove old rank roles: `{error}`"
)).format(error=str(e)))
log_removed = '\n'.join(role.mention for role in to_rm)
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
)
if to_add:
if 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 as e:
logger.warning(
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> "
f"their rank role <rid:{to_add.id}>",
exc_info=True
)
log_errors.append(t(_p(
'eventlog|event:new_rank|error:add_failed',
"Failed to add new rank role: `{error}`"
)).format(error=str(e)))
else:
log_errors.append(t(_p(
'eventlog|event:new_rank|error:add_impossible',
"Could not assign new activity rank role. Lacking permissions or invalid role."
)))
log_added = to_add.mention
else:
log_errors.append(t(_p(
'eventlog|event:new_rank|error:permissions',
"Could not update activity rank roles, I lack the 'Manage Roles' permission."
)))
# Update MemberRank row
column = {
@@ -473,7 +540,29 @@ class RankCog(LionCog):
)
# Send notification
await self._notify_rank_update(guildid, userid, new_rank)
try:
await self._notify_rank_update(guildid, userid, new_rank)
except discord.HTTPException:
log_errors.append(t(_p(
'eventlog|event:new_rank|error:notify_failed',
"Could not notify member."
)))
# Log rank achieved
lguild.log_event(
t(_p(
'eventlog|event:new_rank|name',
"Member Achieved Activity rank"
)),
t(_p(
'eventlog|event:new_rank|desc',
"{member} earned the new activity rank {rank}"
)).format(member=member.mention, rank=f"<@&{new_rank.roleid}>"),
roles_given=log_added,
roles_taken=log_removed,
coins_earned=new_rank.reward,
errors=log_errors,
)
async def _notify_rank_update(self, guildid, userid, new_rank):
"""
@@ -516,11 +605,7 @@ class RankCog(LionCog):
text = member.mention
# Post!
try:
await destination.send(embed=embed, content=text)
except discord.HTTPException:
# TODO: Logging, guild logging, invalidate channel if permissions are wrong
pass
await destination.send(embed=embed, content=text)
def get_message_map(self,
rank_type: RankType,
@@ -777,6 +862,24 @@ class RankCog(LionCog):
self.flush_guild_ranks(guild.id)
await ui.set_done()
# Event log
lguild.log_event(
t(_p(
'eventlog|event:rank_refresh|name',
"Activity Ranks Refreshed"
)),
t(_p(
'eventlog|event:rank_refresh|desc',
"{actor} refresh member activity ranks.\n"
"**`{removed}`** invalid rank roles removed.\n"
"**`{added}`** new rank roles added."
)).format(
actor=interaction.user.mention,
removed=ui.removed,
added=ui.added,
)
)
# ---------- Commands ----------
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
async def ranks_cmd(self, ctx: LionContext):

View File

@@ -6,7 +6,7 @@ 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 meta.errors import ResponseTimedOut, SafeCancellation
from core.data import RankType
from data import ORDER
@@ -16,7 +16,7 @@ from wards import equippable_role
from babel.translator import ctx_translator
from .. import babel, logger
from ..data import AnyRankData
from ..data import AnyRankData, RankData
from ..utils import rank_model_from_type, format_stat_range, stat_data_to_value
from .editor import RankEditor
from .preview import RankPreviewUI
@@ -101,6 +101,7 @@ class RankOverviewUI(MessageUI):
Refresh the current ranks,
ensuring that all members have the correct rank.
"""
await press.response.defer(thinking=True)
async with self.cog.ranklock(self.guild.id):
await self.cog.interactive_rank_refresh(press, self.guild)
@@ -156,11 +157,21 @@ class RankOverviewUI(MessageUI):
Errors if the client does not have permission to create roles.
"""
t = self.bot.translator.t
if not self.guild.me.guild_permissions.manage_roles:
raise SafeCancellation(t(_p(
'ui:rank_overview|button:create|error:my_permissions',
"I lack the 'Manage Roles' permission required to create rank roles!"
)))
async def _create_callback(rank, submit: discord.Interaction):
await submit.response.send_message(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description="Rank Created!"
description=t(_p(
'ui:rank_overview|button:create|success',
"Created a new rank {role}"
)).format(role=f"<@&{rank.roleid}>")
),
ephemeral=True
)

View File

@@ -447,7 +447,7 @@ class Reminders(LionCog):
))
value = 'None'
choices = [
appcmds.Choice(name=name, value=value)
appcmds.Choice(name=name[:100], value=value)
]
else:
# Build list of reminder strings
@@ -463,7 +463,7 @@ class Reminders(LionCog):
# Build list of valid choices
choices = [
appcmds.Choice(
name=string[0],
name=string[0][:100],
value=f"rid:{string[1].reminderid}"
)
for string in matches
@@ -474,7 +474,7 @@ class Reminders(LionCog):
name=t(_p(
'cmd:reminders_cancel|acmpl:reminder|error:no_matches',
"You do not have any reminders matching \"{partial}\""
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
]
@@ -562,7 +562,7 @@ class Reminders(LionCog):
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),
)).format(partial=partial)[:100],
value=partial
)
return [choice]

View File

@@ -14,10 +14,12 @@ from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation
from meta.sharding import THIS_SHARD
from utils.lib import utc_now, error_embed
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now, error_embed, jumpto
from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents
from utils.transformers import DurationTransformer
from utils.monitor import TaskMonitor
from babel.translator import ctx_locale
from constants import MAX_COINS
from data import NULL
@@ -142,6 +144,9 @@ class RoleMenuCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(RoleMenuData())
self.monitor = ComponentMonitor('RoleMenus', self._monitor)
self.ready = asyncio.Event()
# Menu caches
self.live_menus = RoleMenu.attached_menus # guildid -> messageid -> menuid
@@ -149,11 +154,42 @@ class RoleMenuCog(LionCog):
# Expiry manage
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
async def _monitor(self):
state = (
"<"
"RoleMenus"
" ready={ready}"
" cached={cached}"
" views={views}"
" live={live}"
" expiry={expiry}"
">"
)
data = dict(
ready=self.ready.is_set(),
live=sum(len(gmenus) for gmenus in self.live_menus.values()),
expiry=repr(self.expiry_monitor),
cached=len(RoleMenu._menus),
views=len(RoleMenu.menu_views),
)
if not self.ready.is_set():
level = StatusLevel.STARTING
info = f"(STARTING) Not initialised. {state}"
elif not self.expiry_monitor._monitor_task:
level = StatusLevel.ERRORED
info = f"(ERRORED) Expiry monitor not running. {state}"
else:
level = StatusLevel.OKAY
info = f"(OK) RoleMenu loaded and listening. {state}"
return ComponentStatus(level, info, info, data)
# ----- Initialisation -----
async def cog_load(self):
self.bot.system_monitor.add_component(self.monitor)
await self.data.init()
self.bot.tree.add_command(rolemenu_ctxcmd)
self.bot.tree.add_command(rolemenu_ctxcmd, override=True)
if self.bot.is_ready():
await self.initialise()
@@ -164,17 +200,28 @@ class RoleMenuCog(LionCog):
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
self.bot.tree.remove_command(rolemenu_ctxcmd)
@LionCog.listener('on_ready')
@log_wrap(action="Initialise Role Menus")
async def initialise(self):
self.ready.clear()
# Clean up live menu tasks
for menu in list(RoleMenu._menus.values()):
menu.detach()
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
# Start monitor
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
self.expiry_monitor.start()
# Load guilds
guildids = [guild.id for guild in self.bot.guilds]
if guildids:
await self._initialise_guilds(*guildids)
self.ready.set()
async def _initialise_guilds(self, *guildids):
"""
@@ -269,14 +316,85 @@ class RoleMenuCog(LionCog):
menu = await self.data.RoleMenu.fetch(equip_row.menuid)
guild = self.bot.get_guild(menu.guildid)
if guild is not None:
log_errors = []
lguild = await self.bot.core.lions.fetch_guild(menu.guildid)
t = self.bot.translator.t
ctx_locale.set(lguild.locale)
role = guild.get_role(equip_row.roleid)
if role is not None:
lion = await self.bot.core.lions.fetch_member(guild.id, equip_row.userid)
await lion.remove_role(role)
if (member := lion.member):
if role in member.roles:
logger.error(f"Expired {equipid}, but the member still has the role!")
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:remove_failed',
"Removed the role, but the member still has the role!!"
)))
else:
logger.info(f"Expired {equipid}, and successfully removed the role from the member!")
else:
logger.info(
f"Expired {equipid} for non-existent member {equip_row.userid}. "
"Removed from persistent roles."
)
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:member_gone',
"Member could not be found.. role has been removed from saved roles."
)))
else:
logger.info(f"Could not expire {equipid} because the role was not found.")
log_errors.append(t(_p(
'eventlog|event:rolemenu_role_expire|error:no_role',
"Role {role} no longer exists."
)).format(role=f"`{equip_row.roleid}`"))
now = utc_now()
lguild.log_event(
title=t(_p(
'eventlog|event:rolemenu_role_expire|title',
"Equipped role has expired"
)),
description=t(_p(
'eventlog|event:rolemenu_role_expire|desc',
"{member}'s role {role} has now expired."
)).format(
member=f"<@{equip_row.userid}>",
role=f"<@&{equip_row.roleid}>",
),
fields={
t(_p(
'eventlog|event:rolemenu_role_expire|field:menu',
"Obtained From"
)): (
jumpto(
menu.guildid, menu.channelid, menu.messageid
) if menu and menu.messageid else f"**{menu.name}**",
True
),
t(_p(
'eventlog|event:rolemenu_role_expire|field:menu',
"Obtained At"
)): (
discord.utils.format_dt(equip_row.obtained_at),
True
),
t(_p(
'eventlog|event:rolemenu_role_expire|field:expiry',
"Expiry"
)): (
discord.utils.format_dt(equip_row.expires_at),
True
),
},
errors=log_errors
)
await equip_row.update(removed_at=now)
else:
logger.info(f"Could not expire {equipid} because the guild was not found.")
else:
# equipid is no longer valid or is not expiring
logger.info(f"RoleMenu equipped role {equipid} is no longer valid or is not expiring.")
pass
# ----- Private Utils -----
@@ -304,7 +422,7 @@ class RoleMenuCog(LionCog):
error = t(_p(
'parse:message_link|suberror:no_perms',
"Insufficient permissions! I need the `MESSAGE_HISTORY` permission in {channel}."
)).format(channel=channel.menion)
)).format(channel=channel.mention)
else:
error = t(_p(
'parse:message_link|suberror:channel_dne',
@@ -487,7 +605,7 @@ class RoleMenuCog(LionCog):
choice_name = menu.data.name
choice_value = f"menuid:{menu.data.menuid}"
choices.append(
appcmds.Choice(name=choice_name, value=choice_value)
appcmds.Choice(name=choice_name[:100], value=choice_value)
)
if not choices:
@@ -498,7 +616,7 @@ class RoleMenuCog(LionCog):
)).format(partial=partial)
choice_value = partial
choice = appcmds.Choice(
name=choice_name, value=choice_value
name=choice_name[:100], value=choice_value
)
choices.append(choice)
@@ -522,7 +640,7 @@ class RoleMenuCog(LionCog):
"Please select a menu first"
))
choice_value = partial
choices = [appcmds.Choice(name=choice_name, value=choice_value)]
choices = [appcmds.Choice(name=choice_name[:100], value=choice_value)]
else:
# Resolve the menu name
menu: RoleMenu
@@ -544,7 +662,7 @@ class RoleMenuCog(LionCog):
name=t(_p(
'acmpl:menuroles|choice:invalid_menu|name',
"Menu '{name}' does not exist!"
)).format(name=menu_name),
)).format(name=menu_name)[:100],
value=partial
)
choices = [choice]
@@ -564,7 +682,7 @@ class RoleMenuCog(LionCog):
else:
name = mrole.data.label
choice = appcmds.Choice(
name=name,
name=name[:100],
value=f"<@&{mrole.data.roleid}>"
)
choices.append(choice)
@@ -573,7 +691,7 @@ class RoleMenuCog(LionCog):
name=t(_p(
'acmpl:menuroles|choice:no_matching|name',
"No roles in this menu matching '{partial}'"
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
return choices[:25]

View File

@@ -609,7 +609,24 @@ class RoleMenu:
if remove_line:
embed.description = '\n'.join((remove_line, embed.description))
# TODO Event logging
lguild = await self.bot.core.lions.fetch_guild(self.data.guildid)
lguild.log_event(
title=t(_p(
'rolemenu|eventlog|event:role_equipped|title',
"Member equipped role from role menu"
)),
description=t(_p(
'rolemenu|eventlog|event:role_equipped|desc',
"{member} equipped {role} from {menu}"
)).format(
member=member.mention,
role=role.mention,
menu=self.jump_link
),
roles_given=role.mention,
price=price,
expiry=discord.utils.format_dt(expiry) if expiry is not None else None,
)
return embed
async def _handle_negative(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.Embed:
@@ -690,12 +707,29 @@ class RoleMenu:
'rolemenu|deselect|success:norefund|desc',
"You have unequipped **{role}**."
)).format(role=role.name)
lguild = await self.bot.core.lions.fetch_guild(self.data.guildid)
lguild.log_event(
title=t(_p(
'rolemenu|eventlog|event:role_unequipped|title',
"Member unequipped role from role menu"
)),
description=t(_p(
'rolemenu|eventlog|event:role_unequipped|desc',
"{member} unequipped {role} from {menu}"
)).format(
member=member.mention,
role=role.mention,
menu=self.jump_link,
),
roles_given=role.mention,
refund=total_refund,
)
return embed
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
lock_key = ('rmenu', member.id, member.guild.id)
async with self.bot.idlock(lock_key):
# TODO: Selection locking
mrole = self.rolemap.get(menuroleid, None)
if mrole is None:
raise ValueError(

View File

@@ -168,19 +168,34 @@ class RoomCog(LionCog):
async def _destroy_channel_room(self, channel: discord.abc.GuildChannel):
room = self._room_cache[channel.guild.id].get(channel.id, None)
if room is not None:
t = self.bot.translator.t
room.lguild.log_event(
title=t(_p(
'room|eventlog|event:room_deleted|title',
"Private Room Deleted"
)),
description=t(_p(
'room|eventlog|event:room_deleted|desc',
"{owner}'s private room was deleted."
)).format(
owner="<@{mid}>".format(mid=room.data.ownerid),
),
fields=room.eventlog_fields()
)
await room.destroy(reason="Underlying Channel Deleted")
# Setting event handlers
@LionCog.listener('on_guildset_rooms_category')
@log_wrap(action='Update Rooms Category')
async def _update_rooms_category(self, guildid: int, data: Optional[int]):
async def _update_rooms_category(self, guildid: int, setting: RoomSettings.Category):
"""
Move all active private channels to the new category.
This shouldn't affect the channel function at all.
"""
data = setting.data
guild = self.bot.get_guild(guildid)
new_category = guild.get_channel(data) if guild else None
new_category = guild.get_channel(data) if guild and data else None
if new_category:
tasks = []
for room in list(self._room_cache[guildid].values()):
@@ -196,10 +211,11 @@ class RoomCog(LionCog):
@LionCog.listener('on_guildset_rooms_visible')
@log_wrap(action='Update Rooms Visibility')
async def _update_rooms_visibility(self, guildid: int, data: bool):
async def _update_rooms_visibility(self, guildid: int, setting: RoomSettings.Visible):
"""
Update the everyone override on each room to reflect the new setting.
"""
data = setting.data
tasks = []
for room in list(self._room_cache[guildid].values()):
if room.channel:
@@ -226,6 +242,7 @@ class RoomCog(LionCog):
"""
Create a new private room.
"""
t = self.bot.translator.t
lguild = await self.bot.core.lions.fetch_guild(guild.id)
# TODO: Consider extending invites to members rather than giving them immediate access
@@ -245,12 +262,31 @@ class RoomCog(LionCog):
overwrites[member] = member_overwrite
# Create channel
channel = await guild.create_voice_channel(
name=name,
reason=f"Creating Private Room for {owner.id}",
category=lguild.config.get(RoomSettings.Category.setting_id).value,
overwrites=overwrites
)
try:
channel = await guild.create_voice_channel(
name=name,
reason=t(_p(
'create_room|create_channel|audit_reason',
"Creating Private Room for {ownerid}"
)).format(ownerid=owner.id),
category=lguild.config.get(RoomSettings.Category.setting_id).value,
overwrites=overwrites
)
except discord.HTTPException as e:
lguild.log_event(
t(_p(
'eventlog|event:private_room_create_error|name',
"Private Room Creation Failed"
)),
t(_p(
'eventlog|event:private_room_create_error|desc',
"{owner} attempted to rent a new private room, but I could not create it!\n"
"They were not charged."
)).format(owner=owner.mention),
errors=[f"`{repr(e)}`"]
)
raise
try:
# Create Room
now = utc_now()
@@ -287,6 +323,17 @@ class RoomCog(LionCog):
logger.info(
f"New private room created: {room.data!r}"
)
lguild.log_event(
t(_p(
'eventlog|event:private_room_create|name',
"Private Room Rented"
)),
t(_p(
'eventlog|event:private_room_create|desc',
"{owner} has rented a new private room {channel}!"
)).format(owner=owner.mention, channel=channel.mention),
fields=room.eventlog_fields(),
)
return room
@@ -488,7 +535,7 @@ class RoomCog(LionCog):
await ui.send(room.channel)
@log_wrap(action='create_room')
async def _do_create_room(self, ctx, required, days, rent, name, provided) -> Room:
async def _do_create_room(self, ctx, required, days, rent, name, provided) -> Optional[Room]:
t = self.bot.translator.t
# TODO: Rollback the channel create if this fails
async with self.bot.db.connection() as conn:
@@ -543,7 +590,6 @@ class RoomCog(LionCog):
)
)
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
return
except discord.HTTPException as e:
await ctx.reply(
embed=error_embed(
@@ -556,7 +602,6 @@ class RoomCog(LionCog):
)
)
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
return
@room_group.command(
name=_p('cmd:room_status', "status"),

View File

@@ -71,6 +71,48 @@ class Room:
def deleted(self):
return bool(self.data.deleted_at)
def eventlog_fields(self) -> dict[str, tuple[str, bool]]:
t = self.bot.translator.t
fields = {
t(_p(
'room|eventlog|field:owner', "Owner"
)): (
f"<@{self.data.ownerid}>",
True
),
t(_p(
'room|eventlog|field:channel', "Channel"
)): (
f"<#{self.data.channelid}>",
True
),
t(_p(
'room|eventlog|field:balance', "Room Balance"
)): (
f"{self.bot.config.emojis.coin} **{self.data.coin_balance}**",
True
),
t(_p(
'room|eventlog|field:created', "Created At"
)): (
discord.utils.format_dt(self.data.created_at, 'F'),
True
),
t(_p(
'room|eventlog|field:tick', "Next Rent Due"
)): (
discord.utils.format_dt(self.next_tick, 'R'),
True
),
t(_p(
'room|eventlog|field:members', "Private Room Members"
)): (
','.join(f"<@{member}>" for member in self.members),
False
),
}
return fields
async def notify_deposit(self, member: discord.Member, amount: int):
# Assumes locale is set correctly
t = self.bot.translator.t
@@ -108,6 +150,20 @@ class Room:
"Welcome {members}"
)).format(members=', '.join(f"<@{mid}>" for mid in memberids))
)
self.lguild.log_event(
title=t(_p(
'room|eventlog|event:new_members|title',
"Members invited to private room"
)),
description=t(_p(
'room|eventlog|event:new_members|desc',
"{owner} added members to their private room: {members}"
)).format(
members=', '.join(f"<@{mid}>" for mid in memberids),
owner="<@{mid}>".format(mid=self.data.ownerid),
),
fields=self.eventlog_fields()
)
if self.channel:
try:
await self.channel.send(embed=notification)
@@ -128,6 +184,21 @@ class Room:
await member_data.table.delete_where(channelid=self.data.channelid, userid=list(memberids))
self.members = list(set(self.members).difference(memberids))
# No need to notify for removal
t = self.bot.translator.t
self.lguild.log_event(
title=t(_p(
'room|eventlog|event:rm_members|title',
"Members removed from private room"
)),
description=t(_p(
'room|eventlog|event:rm_members|desc',
"{owner} removed members from their private room: {members}"
)).format(
members=', '.join(f"<@{mid}>" for mid in memberids),
owner="<@{mid}>".format(mid=self.data.ownerid),
),
fields=self.eventlog_fields()
)
if self.channel:
guild = self.channel.guild
members = [guild.get_member(memberid) for memberid in memberids]
@@ -255,6 +326,19 @@ class Room:
await owner.send(embed=embed)
except discord.HTTPException:
pass
self.lguild.log_event(
title=t(_p(
'room|eventlog|event:expired|title',
"Private Room Expired"
)),
description=t(_p(
'room|eventlog|event:expired|desc',
"{owner}'s private room has expired."
)).format(
owner="<@{mid}>".format(mid=self.data.ownerid),
),
fields=self.eventlog_fields()
)
await self.destroy(reason='Room Expired')
elif self.channel:
# Notify channel
@@ -274,6 +358,19 @@ class Room:
else:
# No channel means room was deleted
# Just cleanup quietly
self.lguild.log_event(
title=t(_p(
'room|eventlog|event:room_deleted|title',
"Private Room Deleted"
)),
description=t(_p(
'room|eventlog|event:room_deleted|desc',
"{owner}'s private room was deleted."
)).format(
owner="<@{mid}>".format(mid=self.data.ownerid),
),
fields=self.eventlog_fields()
)
await self.destroy(reason='Channel Missing')
@log_wrap(action="Destroy Room")

View File

@@ -904,10 +904,10 @@ class ScheduleCog(LionCog):
if not interaction.guild or not isinstance(interaction.user, discord.Member):
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:book|error:not_in_guild',
"You need to be in a server to book sessions!"
),
))[:100],
value='None'
)
choices = [choice]
@@ -917,10 +917,10 @@ class ScheduleCog(LionCog):
blacklist_role = (await self.settings.BlacklistRole.get(interaction.guild.id)).value
if blacklist_role and blacklist_role in member.roles:
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:book|error:blacklisted',
"Cannot Book -- Blacklisted"
),
))[:100],
value='None'
)
choices = [choice]
@@ -947,7 +947,7 @@ class ScheduleCog(LionCog):
)
choices.append(
appcmds.Choice(
name=tzstring, value='None',
name=tzstring[:100], value='None',
)
)
@@ -968,7 +968,7 @@ class ScheduleCog(LionCog):
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
name=name[:100],
value=str(slotid)
)
)
@@ -978,11 +978,11 @@ class ScheduleCog(LionCog):
name=t(_p(
"cmd:schedule|acmpl:book|no_matching",
"No bookable sessions matching '{partial}'"
)).format(partial=partial[:25]),
)).format(partial=partial[:25])[:100],
value=partial
)
)
return choices
return choices[:25]
@schedule_cmd.autocomplete('cancel')
async def schedule_cmd_cancel_acmpl(self, interaction: discord.Interaction, partial: str):
@@ -998,10 +998,10 @@ class ScheduleCog(LionCog):
can_cancel = list(slotid for slotid in schedule if slotid > minid)
if not can_cancel:
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:cancel|error:empty_schedule',
"You do not have any upcoming sessions to cancel!"
),
))[:100],
value='None'
)
choices.append(choice)
@@ -1025,7 +1025,7 @@ class ScheduleCog(LionCog):
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
name=name[:100],
value=str(slotid)
)
)
@@ -1034,7 +1034,7 @@ class ScheduleCog(LionCog):
name=t(_p(
'cmd:schedule|acmpl:cancel|error:no_matching',
"No cancellable sessions matching '{partial}'"
)).format(partial=partial[:25]),
)).format(partial=partial[:25])[:100],
value='None'
)
choices.append(choice)

View File

@@ -442,7 +442,7 @@ class ScheduledSession:
'session|notify|dm|join_line:channels',
"Please attend your session by joining one of the following:"
))
join_line = '\n'.join(join_line, *(channel.mention for channel in valid[:20]))
join_line = '\n'.join((join_line, *(channel.mention for channel in valid[:20])))
if len(valid) > 20:
join_line += '\n...'

View File

@@ -296,6 +296,23 @@ class ColourShop(Shop):
# TODO: Event log
pass
await self.data.MemberInventory.table.delete_where(inventoryid=owned.data.inventoryid)
else:
owned_role = None
lguild = await self.bot.core.lions.fetch_guild(guild.id)
lguild.log_event(
title=t(_p(
'eventlog|event:purchase_colour|title',
"Member Purchased Colour Role"
)),
description=t(_p(
'eventlog|event:purchase_colour|desc',
"{member} purchased {role} from the colour shop."
)).format(member=member.mention, role=role.mention),
price=item['price'],
roles_given=role.mention,
roles_taken=owned_role.mention if owned_role else None,
)
# Purchase complete, update the shop and customer
await self.refresh()
@@ -446,7 +463,7 @@ class ColourShopping(ShopCog):
),
ephemeral=True
)
await logger.warning(
logger.warning(
"Unexpected Discord exception occurred while creating a colour role.",
exc_info=True
)
@@ -469,8 +486,13 @@ class ColourShopping(ShopCog):
# Due to the imprecise nature of Discord role ordering, this may fail.
try:
role = await role.edit(position=position)
except discord.Forbidden:
position = 0
except discord.HTTPException as e:
if e.code == 50013 or e.status == 403:
# Forbidden case
# But Discord sends its 'Missing Permissions' with a 400 code for position issues
position = 0
else:
raise
# Now that the role is set up, add it to data
item = await self.data.ShopItem.create(
@@ -1090,7 +1112,7 @@ class ColourShopping(ShopCog):
for i, item in enumerate(items, start=1)
]
options = [option for option in options if partial.lower() in option[1].lower()]
return [appcmds.Choice(name=option[1], value=option[0]) for option in options]
return [appcmds.Choice(name=option[1][:100], value=option[0]) for option in options]
class ColourStore(Store):

View File

@@ -0,0 +1,460 @@
from typing import Optional, TYPE_CHECKING
import asyncio
import datetime as dt
import pytz
import discord
from data import ORDER, NULL
from meta import conf, LionBot
from meta.logger import log_wrap
from babel.translator import LazyStr
from . import babel, logger
if TYPE_CHECKING:
from .cog import StatsCog
_p = babel._p
emojis = [
(conf.emojis.active_achievement_1, conf.emojis.inactive_achievement_1),
(conf.emojis.active_achievement_2, conf.emojis.inactive_achievement_2),
(conf.emojis.active_achievement_3, conf.emojis.inactive_achievement_3),
(conf.emojis.active_achievement_4, conf.emojis.inactive_achievement_4),
(conf.emojis.active_achievement_5, conf.emojis.inactive_achievement_5),
(conf.emojis.active_achievement_6, conf.emojis.inactive_achievement_6),
(conf.emojis.active_achievement_7, conf.emojis.inactive_achievement_7),
(conf.emojis.active_achievement_8, conf.emojis.inactive_achievement_8),
]
def progress_bar(value, minimum, maximum, width=10) -> str:
"""
Build a text progress bar representing `value` between `minimum` and `maximum`.
"""
emojis = conf.emojis
proportion = (value - minimum) / (maximum - minimum)
sections = min(max(int(proportion * width), 0), width)
bar = []
# Starting segment
bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full))
# Full segments up to transition or end
if sections >= 2:
bar.append(str(emojis.progress_middle_full) * (sections - 2))
# Transition, if required
if 1 < sections < width:
bar.append(str(emojis.progress_middle_transition))
# Empty sections up to end
if sections < width:
bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1))
# End section
bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full))
# Join all the sections together and return
return ''.join(bar)
class Achievement:
"""
ABC for a member achievement.
"""
# Achievement title
_name: LazyStr
# Text describing achievement
_subtext: LazyStr
# Congratulations text
_congrats: LazyStr = _p(
'achievement|congrats',
"Congratulations! You have completed this challenge."
)
# Index used for visual display of achievement
emoji_index: int
# Achievement threshold
threshold: int
def __init__(self, bot: LionBot, guildid: int, userid: int):
self.bot = bot
self.guildid = guildid
self.userid = userid
self.value: Optional[int] = None
@property
def achieved(self) -> bool:
if self.value is None:
raise ValueError("Cannot get achievement status with no value.")
return self.value >= self.threshold
@property
def progress_text(self) -> str:
if self.value is None:
raise ValueError("Cannot get progress text with no value.")
return f"{int(self.value)}/{int(self.threshold)}"
@property
def name(self) -> str:
return self.bot.translator.t(self._name)
@property
def subtext(self) -> str:
return self.bot.translator.t(self._subtext)
@property
def congrats(self) -> str:
return self.bot.translator.t(self._congrats)
@property
def emoji(self):
return emojis[self.emoji_index][int(not self.achieved)]
@classmethod
async def fetch(cls, bot: LionBot, guildid: int, userid: int):
self = cls(bot, guildid, userid)
await self.update()
return self
def make_field(self):
name = f"{self.emoji} {self.name} ({self.progress_text})"
value = "**0** {bar} **{threshold}**\n*{subtext}*".format(
subtext=self.congrats if self.achieved else self.subtext,
bar=progress_bar(self.value, 0, self.threshold),
threshold=self.threshold
)
return (name, value)
async def update(self):
self.value = await self._calculate()
async def _calculate(self) -> int:
raise NotImplementedError
class Workout(Achievement):
_name = _p(
'achievement:workout|name',
"It's about Power"
)
_subtext = _p(
'achievement:workout|subtext',
"Workout 50 times"
)
threshold = 50
emoji_index = 3
@log_wrap(action='Calc Workout')
async def _calculate(self):
"""
Count the number of completed workout sessions this user has.
"""
record = await self.bot.core.data.workouts.select_one_where(
guildid=self.guildid, userid=self.userid
).select(total='COUNT(*)')
return int(record['total'] or 0)
class VoiceHours(Achievement):
_name = _p(
'achievement:voicehours|name',
"Dream Big"
)
_subtext = _p(
'achievement:voicehours|subtext',
"Study a total of 1000 hours"
)
threshold = 1000
emoji_index = 0
@log_wrap(action='Calc VoiceHours')
async def _calculate(self):
"""
Returns the total number of hours this member has spent in voice.
"""
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
records = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(total='SUM(duration) / 3600').with_no_adapter()
hours = records[0]['total'] if records else 0
return int(hours or 0)
class VoiceStreak(Achievement):
_name = _p(
'achievement:voicestreak|name',
"Consistency is Key"
)
_subtext = _p(
'achievement:voicestreak|subtext',
"Reach a 100-day voice streak"
)
threshold = 100
emoji_index = 1
@log_wrap(action='Calc VoiceStreak')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
# TODO: make this more efficient by calc in database..
history = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(
'start_time', 'end_time'
).order_by('start_time', ORDER.DESC).with_no_adapter()
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
# Streak statistics
streak = 0
max_streak = 0
current_streak = None
day_attended = None
date = lion.today
daydiff = dt.timedelta(days=1)
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
if current_streak is None:
current_streak = streak
max_streak = max(max_streak, streak)
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
return max_streak if max_streak >= self.threshold else current_streak
class Voting(Achievement):
_name = _p(
'achievement:voting|name',
"We're a Team"
)
_subtext = _p(
'achievement:voting|subtext',
"Vote 100 times on top.gg"
)
threshold = 100
emoji_index = 6
@log_wrap(action='Calc Voting')
async def _calculate(self):
record = await self.bot.core.data.topgg.select_one_where(
userid=self.userid
).select(total='COUNT(*)')
return int(record['total'] or 0)
class VoiceDays(Achievement):
_name = _p(
'achievement:days|name',
"Aim For The Moon"
)
_subtext = _p(
'achievement:days|subtext',
"Join Voice on 90 different days"
)
threshold = 90
emoji_index = 2
@log_wrap(action='Calc VoiceDays')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
offset = int(lion.today.utcoffset().total_seconds())
records = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(
total="COUNT(DISTINCT(date_trunc('day', (start_time AT TIME ZONE 'utc') + interval '{} seconds')))".format(offset)
).with_no_adapter()
days = records[0]['total'] if records else 0
return int(days or 0)
class TasksComplete(Achievement):
_name = _p(
'achievement:tasks|name',
"One Step at a Time"
)
_subtext = _p(
'achievement:tasks|subtext',
"Complete 1000 tasks"
)
threshold = 1000
emoji_index = 7
@log_wrap(action='Calc TasksComplete')
async def _calculate(self):
cog = self.bot.get_cog('TasklistCog')
if cog is None:
raise ValueError("Cannot calc TasksComplete without Tasklist Cog")
records = await cog.data.Task.table.select_where(
cog.data.Task.completed_at != NULL,
userid=self.userid,
).select(
total="COUNT(*)"
).with_no_adapter()
completed = records[0]['total'] if records else 0
return int(completed or 0)
class ScheduledSessions(Achievement):
_name = _p(
'achievement:schedule|name',
"Be Accountable"
)
_subtext = _p(
'achievement:schedule|subtext',
"Attend 500 Scheduled Sessions"
)
threshold = 500
emoji_index = 4
@log_wrap(action='Calc ScheduledSessions')
async def _calculate(self):
cog = self.bot.get_cog('ScheduleCog')
if not cog:
raise ValueError("Cannot calc scheduled sessions without ScheduleCog.")
model = cog.data.ScheduleSessionMember
records = await model.table.select_where(
userid=self.userid, guildid=self.guildid, attended=True
).select(
total='COUNT(*)'
).with_no_adapter()
return int((records[0]['total'] or 0) if records else 0)
class MonthlyHours(Achievement):
_name = _p(
'achievement:monthlyhours|name',
"The 30 Days Challenge"
)
_subtext = _p(
'achievement:monthlyhours|subtext',
"Be active for 100 hours in a month"
)
threshold = 100
emoji_index = 5
@log_wrap(action='Calc MonthlyHours')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
records = await stats.data.VoiceSessionStats.table.select_where(
userid=self.userid,
guildid=self.guildid,
).select(
_first='MIN(start_time)'
).with_no_adapter()
first_session = records[0]['_first'] if records else None
if not first_session:
return 0
# Build the list of month start timestamps
month_start = lion.month_start
months = [month_start.astimezone(pytz.utc)]
while month_start >= first_session:
month_start -= dt.timedelta(days=1)
month_start = month_start.replace(day=1)
months.append(month_start.astimezone(pytz.utc))
# Query the study times
times = await stats.data.VoiceSessionStats.study_times_between(
self.guildid, self.userid, *reversed(months), lion.now
)
max_time = max(times) // 3600
return max_time if max_time >= self.threshold else times[-1] // 3600
achievements = [
Workout,
VoiceHours,
VoiceStreak,
Voting,
VoiceDays,
TasksComplete,
ScheduledSessions,
MonthlyHours,
]
achievements.sort(key=lambda cls: cls.emoji_index)
@log_wrap(action='Get Achievements')
async def get_achievements_for(bot: LionBot, guildid: int, userid: int):
"""
Asynchronously fetch achievements for the given member.
"""
member_achieved = [
ach(bot, guildid, userid) for ach in achievements
]
update_tasks = [
asyncio.create_task(ach.update()) for ach in member_achieved
]
await asyncio.gather(*update_tasks)
return member_achieved

View File

@@ -8,14 +8,18 @@ from discord import app_commands as appcmds
from discord.ui.button import ButtonStyle
from meta import LionBot, LionCog, LionContext
from core.lion_guild import VoiceMode
from utils.lib import error_embed
from utils.ui import LeoUI, AButton, utc_now
from gui.base import CardMode
from wards import low_management_ward
from . import babel
from .data import StatsData
from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI
from .settings import StatisticsSettings, StatisticsConfigUI
from .graphics.profilestats import get_full_profile
from .achievements import get_achievements_for
_p = babel._p
@@ -43,7 +47,7 @@ class StatsCog(LionCog):
name=_p('cmd:me', "me"),
description=_p(
'cmd:me|desc',
"Display your personal profile and summary statistics."
"Edit your personal profile and see your statistics."
)
)
@appcmds.guild_only
@@ -53,6 +57,50 @@ class StatsCog(LionCog):
await ui.run(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
name=_p('cmd:profile', 'profile'),
description=_p(
'cmd:profile|desc',
"Display the target's profile and statistics summary."
)
)
@appcmds.rename(
member=_p('cmd:profile|param:member', "member")
)
@appcmds.describe(
member=_p(
'cmd:profile|param:member|desc', "Member to display profile for."
)
)
@appcmds.guild_only
async def profile_cmd(self, ctx: LionContext, member: Optional[discord.Member] = None):
if not ctx.guild:
return
if not ctx.interaction:
return
member = member if member is not None else ctx.author
if member.bot:
# TODO: Localise
await ctx.reply(
"Bots cannot have profiles!",
ephemeral=True
)
return
await ctx.interaction.response.defer(thinking=True)
# Ensure the lion exists
await self.bot.core.lions.fetch_member(member.guild.id, member.id, member=member)
if ctx.lguild.guild_mode.voice:
mode = CardMode.VOICE
else:
mode = CardMode.TEXT
profile_data = await get_full_profile(self.bot, member.id, member.guild.id, mode)
with profile_data:
file = discord.File(profile_data, 'profile.png')
await ctx.reply(file=file)
@cmds.hybrid_command(
name=_p('cmd:stats', "stats"),
description=_p(
@@ -105,6 +153,38 @@ class StatsCog(LionCog):
await ui.run(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
name=_p('cmd:achievements', 'achievements'),
description=_p(
'cmd:achievements|desc',
"View your progress towards the activity achievement awards!"
)
)
@appcmds.guild_only
async def achievements_cmd(self, ctx: LionContext):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
await ctx.interaction.response.defer(thinking=True)
achievements = await get_achievements_for(self.bot, ctx.guild.id, ctx.author.id)
embed = discord.Embed(
title=t(_p(
'cmd:achievements|embed:title',
"Achievements"
)),
colour=discord.Colour.orange()
)
for achievement in achievements:
name, value = achievement.make_field()
embed.add_field(
name=name, value=value, inline=False
)
await ctx.reply(embed=embed)
# Setting commands
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)

View File

@@ -122,7 +122,7 @@ class StatsData(Registry):
"SELECT study_time_between(%s, %s, %s, %s)",
(guildid, userid, _start, _end)
)
return (await cursor.fetchone()[0]) or 0
return (await cursor.fetchone())[0] or 0
@classmethod
@log_wrap(action='study_times_between')
@@ -162,11 +162,11 @@ class StatsData(Registry):
"SELECT study_time_since(%s, %s, %s)",
(guildid, userid, _start)
)
return (await cursor.fetchone()[0]) or 0
return (await cursor.fetchone())[0] or 0
@classmethod
@log_wrap(action='study_times_since')
async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> int:
async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> list[int]:
if len(starts) < 1:
raise ValueError('No starting points given!')
@@ -251,7 +251,7 @@ class StatsData(Registry):
return leaderboard
@classmethod
@log_wrap('leaderboard_all')
@log_wrap(action='leaderboard_all')
async def leaderboard_all(cls, guildid: int):
"""
Return the all-time voice totals for the given guild.

View File

@@ -8,6 +8,7 @@ from gui.cards import ProfileCard
from modules.ranks.cog import RankCog
from modules.ranks.utils import format_stat_range
from ..achievements import get_achievements_for
if TYPE_CHECKING:
from ..cog import StatsCog
@@ -17,11 +18,11 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
ranks: Optional[RankCog] = bot.get_cog('RankCog')
stats: Optional[StatsCog] = bot.get_cog('StatsCog')
if ranks is None or stats is None:
return
raise ValueError("Cannot get profile card without ranks and stats cog loaded.")
guild = bot.get_guild(guildid)
if guild is None:
return
raise ValueError(f"Cannot get profile card without guild {guildid}")
lion = await bot.core.lions.fetch_member(guildid, userid)
luser = lion.luser
@@ -76,14 +77,15 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
else:
next_rank = None
achievements = (0, 1, 2, 3)
achievements = await get_achievements_for(bot, guildid, userid)
achieved = tuple(ach.emoji_index for ach in achievements if ach.achieved)
card = ProfileCard(
user=username,
avatar=(userid, avatar),
coins=lion.data.coins, gems=luser.data.gems, gifts=0,
profile_badges=profile_badges,
achievements=achievements,
achievements=achieved,
current_rank=current_rank,
rank_progress=rank_progress,
next_rank=next_rank

View File

@@ -0,0 +1,62 @@
import asyncio
from io import BytesIO
from PIL import Image
from meta import LionBot
from gui.base import CardMode
from .stats import get_stats_card
from .profile import get_profile_card
card_gap = 10
async def get_full_profile(bot: LionBot, userid: int, guildid: int, mode: CardMode) -> BytesIO:
"""
Render both profile and stats for the target member in the given mode.
Combines the resulting cards into a single image and returns the image data.
"""
# Prepare cards for rendering
get_tasks = (
asyncio.create_task(get_stats_card(bot, userid, guildid, mode), name='get-stats-for-combined'),
asyncio.create_task(get_profile_card(bot, userid, guildid), name='get-profile-for-combined'),
)
stats_card, profile_card = await asyncio.gather(*get_tasks)
# Render cards
render_tasks = (
asyncio.create_task(stats_card.render(), name='render-stats-for-combined'),
asyncio.create_task(profile_card.render(), name='render=profile-for-combined'),
)
# Load the card data into images
stats_data, profile_data = await asyncio.gather(*render_tasks)
with BytesIO(stats_data) as stats_stream, BytesIO(profile_data) as profile_stream:
with Image.open(stats_stream) as stats_image, Image.open(profile_stream) as profile_image:
# Create a new blank image of the correct dimenstions
stats_bbox = stats_image.getbbox(alpha_only=False)
profile_bbox = profile_image.getbbox(alpha_only=False)
if stats_bbox is None or profile_bbox is None:
# Should be impossible, image is already checked by GUI client
raise ValueError("Could not combine, empty stats or profile image.")
combined = Image.new(
'RGBA',
(
max(stats_bbox[2], profile_bbox[2]),
stats_bbox[3] + card_gap + profile_bbox[3]
),
color=None
)
with combined:
combined.alpha_composite(profile_image)
combined.alpha_composite(stats_image, (0, profile_bbox[3] + card_gap))
results = BytesIO()
combined.save(results, format='PNG', compress_type=3, compress_level=1)
results.seek(0)
return results

View File

@@ -6,11 +6,28 @@ import discord
from meta import LionBot
from gui.cards import StatsCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from .. import babel
from ..data import StatsData
_p = babel._p
def format_time(seconds):
return "{:02}:{:02}".format(
int(seconds // 3600),
int(seconds % 3600 // 60)
)
def format_xp(messages, xp):
return f"{messages} ({xp} XP)"
async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode):
t = bot.translator.t
data: StatsData = bot.get_cog('StatsCog').data
# TODO: Workouts
@@ -32,28 +49,41 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
)
# Extract the study times for each period
if mode in (CardMode.STUDY, CardMode.VOICE):
if mode in (CardMode.STUDY, CardMode.VOICE, CardMode.ANKI):
model = data.VoiceSessionStats
refkey = (guildid or None, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
period_activity = await ref_since(*refkey, *period_timestamps)
period_strings = [format_time(activity) for activity in reversed(period_activity)]
month_activity = period_activity[1]
month_string = t(_p(
'gui:stats|mode:voice|month',
"{hours} hours"
)).format(hours=int(month_activity // 3600))
elif mode is CardMode.TEXT:
msgmodel = TextTrackerData.TextSessions
if guildid:
model = data.MemberExp
msg_since = msgmodel.member_messages_since
refkey = (guildid, userid)
else:
model = data.UserExp
msg_since = msgmodel.member_messages_between
refkey = (userid,)
ref_since = model.xp_since
ref_between = model.xp_between
else:
# TODO ANKI
model = data.VoiceSessionStats
refkey = (guildid, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
study_times = await ref_since(*refkey, *period_timestamps)
xp_period_activity = await ref_since(*refkey, *period_timestamps)
msg_period_activity = await msg_since(*refkey, *period_timestamps)
period_strings = [
format_xp(msgs, xp)
for msgs, xp in zip(reversed(msg_period_activity), reversed(xp_period_activity))
]
month_string = f"{xp_period_activity[1]} XP"
else:
raise ValueError(f"Mode {mode} not supported")
# Get leaderboard position
# TODO: Efficiency
@@ -89,7 +119,8 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
card = StatsCard(
(position, 0),
list(reversed(study_times)),
period_strings,
month_string,
100,
streaks,
skin={'mode': mode}

View File

@@ -41,7 +41,7 @@ class StatsUI(LeoUI):
"""
ID of guild to render stats for, or None if global.
"""
return self.guild.id if not self._showing_global else None
return self.guild.id if self.guild and not self._showing_global else None
@property
def userid(self) -> int:
@@ -67,7 +67,8 @@ class StatsUI(LeoUI):
Delete the output message and close the UI.
"""
await press.response.defer()
await self._original.delete_original_response()
if self._original and not self._original.is_expired():
await self._original.delete_original_response()
self._original = None
await self.close()
@@ -93,7 +94,10 @@ class StatsUI(LeoUI):
args = await self.make_message()
if thinking is not None and not thinking.is_expired() and thinking.response.is_done():
asyncio.create_task(thinking.delete_original_response())
await self._original.edit_original_response(**args.edit_args, view=self)
if self._original and not self._original.is_expired():
await self._original.edit_original_response(**args.edit_args, view=self)
else:
await self.close()
async def refresh(self, thinking: Optional[discord.Interaction] = None):
"""

View File

@@ -41,6 +41,7 @@ class StatType(IntEnum):
class LeaderboardUI(StatsUI):
page_size = 10
guildid: int
def __init__(self, bot, user, guild, **kwargs):
super().__init__(bot, user, guild, **kwargs)
@@ -199,6 +200,9 @@ class LeaderboardUI(StatsUI):
mode = CardMode.TEXT
elif self.stat_type is StatType.ANKI:
mode = CardMode.ANKI
else:
raise ValueError
card = await get_leaderboard_card(
self.bot, self.userid, self.guildid,
mode,

View File

@@ -166,7 +166,7 @@ class ProfileUI(StatsUI):
t = self.bot.translator.t
data: StatsData = self.bot.get_cog('StatsCog').data
tags = await data.ProfileTag.fetch_tags(self.guildid, self.userid)
tags = await data.ProfileTag.fetch_tags(self.guild.id, self.userid)
modal = ProfileEditor()
modal.editor.default = '\n'.join(tags)
@@ -177,7 +177,7 @@ class ProfileUI(StatsUI):
await interaction.response.defer(thinking=True, ephemeral=True)
# Set the new tags and refresh
await data.ProfileTag.set_tags(self.guildid, self.userid, new_tags)
await data.ProfileTag.set_tags(self.guild.id, self.userid, new_tags)
if self._original is not None:
self._profile_card = None
await self.refresh(thinking=interaction)
@@ -310,7 +310,7 @@ class ProfileUI(StatsUI):
"""
Create and render the XP and stats cards.
"""
card = await get_profile_card(self.bot, self.userid, self.guildid)
card = await get_profile_card(self.bot, self.userid, self.guild.id)
if card:
await card.render()
self._profile_card = card

View File

@@ -329,7 +329,7 @@ class Exec(LionCog):
results = [
appcmd.Choice(name=f"No peers found matching {partial}", value=partial)
]
return results
return results[:25]
async_cmd.autocomplete('target')(_peer_acmpl)

View File

@@ -242,6 +242,7 @@ class PresenceCtrl(LionCog):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
leo_setting_cog.bot_setting_groups.append(self.settings)
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
await self.reload_presence()
self.update_listeners()
@@ -372,7 +373,12 @@ class PresenceCtrl(LionCog):
"Unhandled exception occurred running client presence update loop. Closing loop."
)
@cmds.hybrid_command(
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def leo_group(self, ctx: LionContext):
...
@leo_group.command(
name="presence",
description="Globally set the bot status and activity."
)

View File

@@ -291,7 +291,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|error:no_tasks',
"Tasklist empty! No matching tasks."
)),
))[:100],
value=partial
)
]
@@ -319,7 +319,7 @@ class TasklistCog(LionCog):
if matching:
# If matches were found, assume user wants one of the matches
options = [
appcmds.Choice(name=task_string, value=label)
appcmds.Choice(name=task_string[:100], value=label)
for label, task_string in matching
]
elif multi and partial.lower().strip() in ('-', 'all'):
@@ -328,7 +328,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|match:all',
"All tasks"
)),
))[:100],
value='-'
)
]
@@ -353,7 +353,7 @@ class TasklistCog(LionCog):
multi_name = f"{partial[:remaining-1]} {error}"
multi_option = appcmds.Choice(
name=multi_name,
name=multi_name[:100],
value=partial
)
options = [multi_option]
@@ -371,7 +371,7 @@ class TasklistCog(LionCog):
if not matching:
matching = [(label, task) for label, task in labels if last_split.lower() in task.lower()]
options.extend(
appcmds.Choice(name=task_string, value=label)
appcmds.Choice(name=task_string[:100], value=label)
for label, task_string in matching
)
else:
@@ -380,7 +380,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|error:no_matching',
"No tasks matching '{partial}'!",
)).format(partial=partial[:100]),
)).format(partial=partial[:100])[:100],
value=partial
)
]

View File

@@ -728,7 +728,7 @@ class TasklistUI(BasePager):
)
try:
await press.user.send(contents, file=file, silent=True)
except discord.HTTPClient:
except discord.HTTPException:
fp.seek(0)
file = discord.File(fp, filename='tasklist.md')
await press.followup.send(
@@ -736,7 +736,7 @@ class TasklistUI(BasePager):
'ui:tasklist|button:save|error:dms',
"Could not DM you! Do you have me blocked? Tasklist attached below."
)),
file=file
file=file,
)
else:
fp.seek(0)

View File

@@ -393,7 +393,7 @@ class VideoCog(LionCog):
only_warn = True
# Ack based on ticket created
alert_ref = message.to_reference(fail_if_not_exists=False)
alert_ref = message.to_reference(fail_if_not_exists=False) if message else None
if only_warn:
# TODO: Warn ticket
warning = discord.Embed(

View File

@@ -35,6 +35,8 @@ class VideoTicket(Ticket):
**kwargs
)
await ticket_data.update(created_at=utc_now().replace(tzinfo=None))
lguild = await bot.core.lions.fetch_guild(member.guild.id, guild=member.guild)
new_ticket = cls(lguild, ticket_data)

View File

@@ -237,7 +237,7 @@ class ChannelSetting(Generic[ParentID, CT], InteractiveSetting[ParentID, int, CT
_selector_placeholder = "Select a Channel"
channel_types: list[discord.ChannelType] = []
_allow_object = True
_allow_object = False
@classmethod
def _data_from_value(cls, parent_id, value, **kwargs):
@@ -368,7 +368,7 @@ class RoleSetting(InteractiveSetting[ParentID, int, Union[discord.Role, discord.
_accepts = _p('settype:role|accepts', "A role name or id")
_selector_placeholder = "Select a Role"
_allow_object = True
_allow_object = False
@classmethod
def _get_guildid(cls, parent_id: int, **kwargs) -> int:
@@ -915,7 +915,7 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]):
name=t(_p(
'set_type:timezone|acmpl|no_matching',
"No timezones matching '{input}'!"
)).format(input=partial),
)).format(input=partial)[:100],
value=partial
)
]
@@ -930,7 +930,7 @@ class TimezoneSetting(InteractiveSetting[ParentID, str, TZT]):
"{tz} (Currently {now})"
)).format(tz=tz, now=nowstr)
choice = appcmds.Choice(
name=name,
name=name[:100],
value=tz
)
choices.append(choice)

View File

@@ -236,7 +236,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
Callable[[ParentID, SettingData], Coroutine[Any, Any, None]]
"""
if self._event is not None and (bot := ctx_bot.get()) is not None:
bot.dispatch(self._event, self.parent_id, self.data)
bot.dispatch(self._event, self.parent_id, self)
def get_listener(self, key):
return self._listeners_.get(key, None)
@@ -453,6 +453,12 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
data = await cls._parse_string(parent_id, userstr, **kwargs)
return cls(parent_id, data, **kwargs)
@classmethod
async def from_value(cls, parent_id, value, **kwargs):
await cls._check_value(parent_id, value, **kwargs)
data = cls._data_from_value(parent_id, value, **kwargs)
return cls(parent_id, data, **kwargs)
@classmethod
async def _parse_string(cls, parent_id, string: str, **kwargs) -> Optional[SettingData]:
"""
@@ -471,15 +477,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
raise NotImplementedError
@classmethod
async def _check_value(cls, parent_id, value, **kwargs) -> Optional[str]:
async def _check_value(cls, parent_id, value, **kwargs):
"""
Check the provided value is valid.
Many setting update methods now provide Discord objects instead of raw data or user strings.
This method may be used for value-checking such a value.
Returns `None` if there are no issues, otherwise an error message.
Subclasses should override this to implement a value checker.
Raises UserInputError if the value fails validation.
"""
pass

View File

@@ -13,6 +13,7 @@ from meta.errors import UserInputError
from meta.logger import log_wrap, logging_context
from meta.sharding import THIS_SHARD
from meta.app import appname
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now, error_embed
from wards import low_management_ward, sys_admin_ward
@@ -42,10 +43,14 @@ class TextTrackerCog(LionCog):
self.data = bot.db.load_registry(TextTrackerData())
self.settings = TextTrackerSettings()
self.global_settings = TextTrackerGlobalSettings()
self.monitor = ComponentMonitor('TextTracker', self._monitor)
self.babel = babel
self.sessionq = asyncio.Queue(maxsize=0)
self.ready = asyncio.Event()
self.errors = 0
# Map of ongoing text sessions
# guildid -> (userid -> TextSession)
self.ongoing = defaultdict(dict)
@@ -54,7 +59,41 @@ class TextTrackerCog(LionCog):
self.untracked_channels = self.settings.UntrackedTextChannels._cache
async def _monitor(self):
state = (
"<"
"TextTracker"
" ready={ready}"
" queued={queued}"
" errors={errors}"
" running={running}"
" consumer={consumer}"
">"
)
data = dict(
ready=self.ready.is_set(),
queued=self.sessionq.qsize(),
errors=self.errors,
running=sum(len(usessions) for usessions in self.ongoing.values()),
consumer="'Running'" if (self._consumer_task and not self._consumer_task.done()) else "'Not Running'",
)
if not self.ready.is_set():
level = StatusLevel.STARTING
info = f"(STARTING) Not initialised. {state}"
elif not self._consumer_task:
level = StatusLevel.ERRORED
info = f"(ERROR) Consumer task not running. {state}"
elif self.errors > 1:
level = StatusLevel.UNSURE
info = f"(UNSURE) Errors occurred while consuming. {state}"
else:
level = StatusLevel.OKAY
info = f"(OK) Message tracking operational. {state}"
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.XPPerPeriod)
@@ -83,6 +122,7 @@ class TextTrackerCog(LionCog):
await self.initialise()
async def cog_unload(self):
self.ready.clear()
if self._consumer_task is not None:
self._consumer_task.cancel()
@@ -104,7 +144,7 @@ class TextTrackerCog(LionCog):
await self.bot.core.lions.fetch_member(session.guildid, session.userid)
self.sessionq.put_nowait(session)
@log_wrap(stack=['Text Sessions', 'Message Event'])
@log_wrap(stack=['Text Sessions', 'Consumer'])
async def _session_consumer(self):
"""
Process completed sessions in batches of length `batchsize`.
@@ -132,6 +172,7 @@ class TextTrackerCog(LionCog):
logger.exception(
"Unknown exception processing batch of text sessions! Discarding and continuing."
)
self.errors += 1
batch = []
counter = 0
last_time = time.monotonic()
@@ -157,6 +198,9 @@ class TextTrackerCog(LionCog):
# Batch-fetch lguilds
lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch})
await self.bot.core.lions.fetch_members(
*((session.guildid, session.userid) for session in batch)
)
# Build data
rows = []
@@ -202,9 +246,11 @@ class TextTrackerCog(LionCog):
"""
Launch the session consumer.
"""
self.ready.clear()
if self._consumer_task and not self._consumer_task.cancelled():
self._consumer_task.cancel()
self._consumer_task = asyncio.create_task(self._session_consumer())
self._consumer_task = asyncio.create_task(self._session_consumer(), name='text-session-consumer')
self.ready.set()
logger.info("Launched text session consumer.")
@LionCog.listener('on_message')

View File

@@ -288,6 +288,42 @@ class TextTrackerData(Registry):
tuple(chain((userid, guildid), points))
)
return [r['messages'] or 0 for r in await cursor.fetchall()]
@classmethod
@log_wrap(action='user_messages_since')
async def user_messages_since(cls, userid: int, *points):
"""
Compute messages written between the given points.
"""
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(messages)
FROM text_sessions s
WHERE
s.userid = %s
AND s.start_time >= t._start
) AS messages
FROM
(VALUES {})
AS
t (_start)
ORDER BY t._start
"""
).format(
sql.SQL(', ').join(
sql.SQL("({})").format(sql.Placeholder()) for _ in points
)
)
async with cls._connector.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((userid,), points))
)
return [r['messages'] or 0 for r in await cursor.fetchall()]
@classmethod
@log_wrap(action='msgs_leaderboard_all')
@@ -301,7 +337,7 @@ class TextTrackerData(Registry):
FROM text_sessions
WHERE guildid = %s AND start_time >= %s
GROUP BY userid
ORDER BY
ORDER BY user_total DESC
"""
)
async with cls._connector.connection() as conn:
@@ -325,7 +361,7 @@ class TextTrackerData(Registry):
FROM text_sessions
WHERE guildid = %s
GROUP BY userid
ORDER BY
ORDER BY user_total DESC
"""
)
async with cls._connector.connection() as conn:

View File

@@ -1,17 +1,18 @@
from typing import Optional
import asyncio
import itertools
import datetime as dt
from collections import defaultdict
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from data import Condition
from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError
from meta.logger import log_wrap, logging_context
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from utils.lib import utc_now, error_embed
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now
from core.lion_guild import VoiceMode
from wards import low_management_ward, moderator_ctxward
@@ -35,6 +36,7 @@ class VoiceTrackerCog(LionCog):
self.data = bot.db.load_registry(VoiceTrackerData())
self.settings = VoiceTrackerSettings()
self.babel = babel
self.monitor = ComponentMonitor('VoiceTracker', self._monitor)
# State
# Flag indicating whether local voice sessions have been initialised
@@ -44,7 +46,77 @@ class VoiceTrackerCog(LionCog):
self.untracked_channels = self.settings.UntrackedChannels._cache
self.active_sessions = VoiceSession._active_sessions_
async def _monitor(self):
state = (
"<"
"VoiceTracker"
" initialised={initialised}"
" active={active}"
" pending={pending}"
" ongoing={ongoing}"
" locked={locked}"
" actual={actual}"
" channels={channels}"
" cached={cached}"
" initial_event={initial_event}"
" lock={lock}"
">"
)
data = dict(
initialised=self.initialised.is_set(),
active=0,
pending=0,
ongoing=0,
locked=0,
actual=0,
channels=0,
cached=sum(len(gsessions) for gsessions in VoiceSession._sessions_.values()),
initial_event=self.initialised,
lock=self.tracking_lock
)
channels = set()
for tguild in self.active_sessions.values():
for session in tguild.values():
data['active'] += 1
if session.activity is SessionState.ONGOING:
data['ongoing'] += 1
elif session.activity is SessionState.PENDING:
data['pending'] += 1
if session.lock.locked():
data['locked'] += 1
if session.state:
channels.add(session.state.channelid)
data['channels'] = len(channels)
for guild in self.bot.guilds:
for channel in itertools.chain(guild.voice_channels, guild.stage_channels):
if not self.is_untracked(channel):
for member in channel.members:
if member.voice and not member.bot:
data['actual'] += 1
if not self.initialised.is_set():
level = StatusLevel.STARTING
info = f"(STARTING) Not initialised. {state}"
elif self.tracking_lock.locked():
level = StatusLevel.WAITING
info = f"(WAITING) Waiting for tracking lock. {state}"
elif data['actual'] != data['active']:
level = StatusLevel.UNSURE
info = f"(UNSURE) Actual sessions do not match active. {state}"
else:
level = StatusLevel.OKAY
info = f"(OK) Voice tracking operational. {state}"
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.HourlyReward)
@@ -71,7 +143,8 @@ class VoiceTrackerCog(LionCog):
# Simultaneously!
...
def get_session(self, guildid, userid, **kwargs) -> VoiceSession:
# ----- Cog API -----
def get_session(self, guildid, userid, **kwargs):
"""
Get the VoiceSession for the given member.
@@ -91,6 +164,199 @@ class VoiceTrackerCog(LionCog):
untracked = False
return untracked
@log_wrap(action='load sessions')
async def _load_sessions(self,
states: dict[tuple[int, int], TrackedVoiceState],
ongoing: list[VoiceTrackerData.VoiceSessionsOngoing]):
"""
Load voice sessions from provided states and ongoing data.
Provided data may cross multiple guilds.
Assumes all states which do not have data should be started.
Assumes all ongoing data which does not have states should be ended.
Assumes untracked channel data is up to date.
"""
OngoingData = VoiceTrackerData.VoiceSessionsOngoing
# Compute time to end complete sessions
now = utc_now()
last_update = max((row.last_update for row in ongoing), default=now)
end_at = min(last_update + dt.timedelta(seconds=3600), now)
# Bulk fetches for voice-active members and guilds
active_memberids = list(states.keys())
active_guildids = set(gid for gid, _ in states)
if states:
lguilds = await self.bot.core.lions.fetch_guilds(*active_guildids)
await self.bot.core.lions.fetch_members(*active_memberids)
tracked_today_data = await self.data.VoiceSessions.multiple_voice_tracked_since(
*((guildid, userid, lguilds[guildid].today) for guildid, userid in active_memberids)
)
tracked_today = {(row['guildid'], row['userid']): row['tracked'] for row in tracked_today_data}
else:
lguilds = {}
tracked_today = {}
# Zip session information together by memberid keys
sessions: dict[tuple[int, int], tuple[Optional[TrackedVoiceState], Optional[OngoingData]]] = {}
for row in ongoing:
key = (row.guildid, row.userid)
sessions[key] = (states.pop(key, None), row)
for key, state in states.items():
sessions[key] = (state, None)
# Now split up session information to fill action maps
close_ongoing = []
update_ongoing = []
create_ongoing = []
expiries = {}
load_sessions = []
schedule_sessions = {}
for (gid, uid), (state, data) in sessions.items():
if state is not None:
# Member is active
if data is not None and data.channelid != state.channelid:
# Ongoing session does not match active state
# Close the session, but still create/schedule the state
close_ongoing.append((gid, uid, end_at))
data = None
# Now create/update/schedule active session
# Also create/update data if required
lguild = lguilds[gid]
tomorrow = lguild.today + dt.timedelta(days=1)
cap = lguild.config.get('daily_voice_cap').value
tracked = tracked_today[gid, uid]
hourly_rate = await self._calculate_rate(gid, uid, state)
if tracked >= cap:
# Active session is already over cap
# Stop ongoing if it exists, and schedule next session start
delay = (tomorrow - now).total_seconds()
start_time = tomorrow
expiry = tomorrow + dt.timedelta(seconds=cap)
schedule_sessions[(gid, uid)] = (delay, start_time, expiry, state, hourly_rate)
if data is not None:
close_ongoing.append((
gid, uid,
max(now - dt.timedelta(seconds=tracked - cap), data.last_update)
))
else:
# Active session, update/create data
expiry = now + dt.timedelta(seconds=(cap - tracked))
if expiry > tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
expiries[(gid, uid)] = expiry
if data is not None:
update_ongoing.append((gid, uid, now, state.stream, state.video, hourly_rate))
else:
create_ongoing.append((
gid, uid, state.channelid, now, now, state.stream, state.video, hourly_rate
))
elif data is not None:
# Ongoing data has no state, close the session
close_ongoing.append((gid, uid, end_at))
# Close data that needs closing
if close_ongoing:
logger.info(
f"Ending {len(close_ongoing)} ongoing voice sessions with no matching voice state."
)
await self.data.VoiceSessionsOngoing.close_voice_sessions_at(*close_ongoing)
# Update data that needs updating
if update_ongoing:
logger.info(
f"Continuing {len(update_ongoing)} ongoing voice sessions with matching voice state."
)
rows = await self.data.VoiceSessionsOngoing.update_voice_sessions_at(*update_ongoing)
load_sessions.extend(rows)
# Create data that needs creating
if create_ongoing:
logger.info(
f"Creating {len(create_ongoing)} voice sessions from new voice states."
)
# First ensure the tracked channels exist
cids = set((item[2], item[0]) for item in create_ongoing)
await self.data.TrackedChannel.fetch_multiple(*cids)
# Then create the sessions
rows = await self.data.VoiceSessionsOngoing.table.insert_many(
('guildid', 'userid', 'channelid', 'start_time', 'last_update', 'live_stream',
'live_video', 'hourly_coins'),
*create_ongoing
).with_adapter(self.data.VoiceSessionsOngoing._make_rows)
load_sessions.extend(rows)
# Create sessions from ongoing, with expiry
for row in load_sessions:
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
# Schedule starting sessions
for (gid, uid), args in schedule_sessions.items():
session = VoiceSession.get(self.bot, gid, uid)
await session.schedule_start(*args)
logger.info(
f"Successfully loaded {len(load_sessions)} and scheduled {len(schedule_sessions)} voice sessions."
)
@log_wrap(action='refresh guild sessions')
async def refresh_guild_sessions(self, guild: discord.Guild):
"""
Idempotently refresh all guild voice sessions in the given guild.
Essentially a lighter version of `initialise`.
"""
# TODO: There is a very small potential window for a race condition here
# Since we do not have a version of 'handle_events' for the guild
# We may actually handle events before starting refresh
# Causing sessions to have invalid state.
# If this becomes an actual problem, implement an `ignore_guilds` set flag of some form...
logger.debug(f"Beginning voice state refresh for <gid: {guild.id}>")
async with self.tracking_lock:
# TODO: Add a 'lock holder' attribute which is readable by the monitor
logger.debug(f"Voice state refresh for <gid: {guild.id}> is past lock")
# Deactivate any ongoing session tasks in this guild
active = self.active_sessions.pop(guild.id, {}).values()
for session in active:
session.cancel()
# Clear registry
VoiceSession._sessions_.pop(guild.id, None)
# Update untracked channel information for this guild
self.untracked_channels.pop(guild.id, None)
await self.settings.UntrackedChannels.get(guild.id)
# Read tracked voice states
states = {}
for channel in itertools.chain(guild.voice_channels, guild.stage_channels):
if not self.is_untracked(channel):
for member in channel.members:
if member.voice and not member.bot:
state = TrackedVoiceState.from_voice_state(member.voice)
states[(guild.id, member.id)] = state
logger.debug(f"Loaded {len(states)} tracked voice states for <gid: {guild.id}>.")
# Read ongoing session data
ongoing = await self.data.VoiceSessionsOngoing.fetch_where(guildid=guild.id)
logger.debug(
f"Loaded {len(ongoing)} ongoing voice sessions from data for <gid: {guild.id}>. Beginning reload."
)
await self._load_sessions(states, ongoing)
logger.info(
f"Completed guild voice session reload for <gid: {guild.id}> "
f"with '{len(self.active_sessions[guild.id])}' active sessions."
)
# ----- Event Handlers -----
@LionCog.listener('on_ready')
@log_wrap(action='Init Voice Sessions')
async def initialise(self):
@@ -99,192 +365,54 @@ class VoiceTrackerCog(LionCog):
Ends ongoing sessions for members who are not in the given voice channel.
"""
# First take the tracking lock
# Ensures current event handling completes before re-initialisation
logger.info("Beginning voice session state initialisation. Disabling voice event handling.")
# If `on_ready` is called, that means we are initialising
# or we missed events and need to re-initialise.
# Start ignoring events because they may be working on stale or partial state
self.handle_events = False
# Services which read our cache should wait for initialisation before taking the lock
self.initialised.clear()
# Wait for running events to complete
# And make sure future events will be processed after initialisation
# Note only events occurring after our voice state snapshot will be processed
async with self.tracking_lock:
logger.info("Reloading ongoing voice sessions")
# Deactivate all ongoing sessions
active = [session for gsessions in self.active_sessions.values() for session in gsessions.values()]
for session in active:
session.cancel()
self.active_sessions.clear()
# Also clear the session registry cache
VoiceSession._sessions_.clear()
# Refresh untracked information for all guilds we are in
await self.settings.UntrackedChannels.setup(self.bot)
logger.debug("Disabling voice state event handling.")
self.handle_events = False
self.initialised.clear()
# Read and save the tracked voice states of all visible voice channels
voice_members = {} # (guildid, userid) -> TrackedVoiceState
voice_guilds = set()
states = {}
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 channel in itertools.chain(guild.voice_channels, guild.stage_channels):
if not self.is_untracked(channel):
for member in channel.members:
if member.voice and not member.bot:
state = TrackedVoiceState.from_voice_state(member.voice)
states[(guild.id, member.id)] = state
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)
logger.debug(f"Cached {len(voice_members)} members from voice channels.")
logger.info(
f"Saved voice snapshot with {len(states)} tracked states. Re-enabling voice event handling."
)
self.handle_events = True
logger.debug("Re-enabled voice state event handling.")
# Iterate through members with current ongoing sessions
# End or update sessions as needed, based on saved tracked state
ongoing_rows = await self.data.VoiceSessionsOngoing.fetch_where(
guildid=[guild.id for guild in self.bot.guilds]
# Load ongoing session data for the entire shard
ongoing = await self.data.VoiceSessionsOngoing.fetch_where(THIS_SHARD)
logger.info(
f"Retrieved {len(ongoing)} ongoing voice sessions from data. Beginning reload."
)
logger.debug(
f"Loaded {len(ongoing_rows)} ongoing sessions from data. Splitting into complete and incomplete."
)
complete = []
incomplete = []
incomplete_guildids = set()
# Compute time to end complete sessions
now = utc_now()
last_update = max((row.last_update for row in ongoing_rows), default=now)
end_at = min(last_update + dt.timedelta(seconds=3600), now)
await self._load_sessions(states, ongoing)
for row in ongoing_rows:
key = (row.guildid, row.userid)
state = voice_members.get(key, None)
untracked = self.untracked_channels.get(row.guildid, [])
if (
state
and state.channelid == row.channelid
and state.channelid not in untracked
and (ch := self.bot.get_channel(state.channelid)) is not None
and (not ch.category_id or ch.category_id not in untracked)
):
# Mark session as ongoing
incomplete.append((row, state))
incomplete_guildids.add(row.guildid)
voice_members.pop(key)
else:
# Mark session as complete
complete.append((row.guildid, row.userid, end_at))
# Load required guild data into cache
active_guildids = incomplete_guildids.union(voice_guilds)
if active_guildids:
await self.bot.core.data.Guild.fetch_where(guildid=tuple(active_guildids))
lguilds = {guildid: await self.bot.core.lions.fetch_guild(guildid) for guildid in active_guildids}
# Calculate tracked_today for members with ongoing sessions
active_members = set((row.guildid, row.userid) for row, _ in incomplete)
active_members.update(voice_members.keys())
if active_members:
tracked_today_data = await self.data.VoiceSessions.multiple_voice_tracked_since(
*((guildid, userid, lguilds[guildid].today) for guildid, userid in active_members)
)
else:
tracked_today_data = []
tracked_today = {(row['guildid'], row['userid']): row['tracked'] for row in tracked_today_data}
if incomplete:
# Note that study_time_since _includes_ ongoing sessions in its calculation
# So expiry times are "time left today until cap" or "tomorrow + cap"
to_load = [] # (session_data, expiry_time)
to_update = [] # (guildid, userid, update_at, stream, video, hourly_rate)
for session_data, state in incomplete:
# Calculate expiry times
lguild = lguilds[session_data.guildid]
cap = lguild.config.get('daily_voice_cap').value
tracked = tracked_today[(session_data.guildid, session_data.userid)]
if tracked >= cap:
# Already over cap
complete.append((
session_data.guildid,
session_data.userid,
max(now + dt.timedelta(seconds=tracked - cap), session_data.last_update)
))
else:
tomorrow = lguild.today + dt.timedelta(days=1)
expiry = now + dt.timedelta(seconds=(cap - tracked))
if expiry > tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
to_load.append((session_data, expiry))
# TODO: Probably better to do this by batch
# Could force all bonus calculators to accept list of members
hourly_rate = await self._calculate_rate(session_data.guildid, session_data.userid, state)
to_update.append((
session_data.guildid,
session_data.userid,
now,
state.stream,
state.video,
hourly_rate
))
# Run the updates, note that session_data uses registry pattern so will also update
if to_update:
await self.data.VoiceSessionsOngoing.update_voice_sessions_at(*to_update)
# Load the sessions
for data, expiry in to_load:
VoiceSession.from_ongoing(self.bot, data, expiry)
logger.info(f"Resumed {len(to_load)} ongoing voice sessions.")
if complete:
logger.info(f"Ending {len(complete)} out-of-date or expired study sessions.")
# Complete sessions just need a mass end_voice_session_at()
await self.data.VoiceSessionsOngoing.close_voice_sessions_at(*complete)
# Then iterate through the saved states from tracked voice channels
# Start sessions if they don't already exist
if voice_members:
expiries = {} # (guildid, memberid) -> expiry time
to_create = [] # (guildid, userid, channelid, start_time, last_update, live_stream, live_video, rate)
for (guildid, userid), state in voice_members.items():
untracked = self.untracked_channels.get(guildid, [])
channel = self.bot.get_channel(state.channelid)
if (
channel
and channel.id not in untracked
and (not channel.category_id or channel.category_id not in untracked)
):
# State is from member in tracked voice channel
# Calculate expiry
lguild = lguilds[guildid]
cap = lguild.config.get('daily_voice_cap').value
tracked = tracked_today[(guildid, userid)]
if tracked < cap:
tomorrow = lguild.today + dt.timedelta(days=1)
expiry = now + dt.timedelta(seconds=(cap - tracked))
if expiry > tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
expiries[(guildid, userid)] = expiry
hourly_rate = await self._calculate_rate(guildid, userid, state)
to_create.append((
guildid, userid,
state.channelid,
now, now,
state.stream, state.video,
hourly_rate
))
# Bulk create the ongoing sessions
if to_create:
# First ensure the lion members exist
await self.bot.core.lions.fetch_members(
*(item[:2] for item in to_create)
)
# Then ensure the TrackedChannels exist
cids = set((item[2], item[0]) for item in to_create)
await self.data.TrackedChannel.fetch_multiple(*cids)
# Then actually create the ongoing sessions
rows = await self.data.VoiceSessionsOngoing.table.insert_many(
('guildid', 'userid', 'channelid', 'start_time', 'last_update', 'live_stream',
'live_video', 'hourly_coins'),
*to_create
).with_adapter(self.data.VoiceSessionsOngoing._make_rows)
for row in rows:
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
logger.info(f"Started {len(rows)} new voice sessions from voice channels!")
self.initialised.set()
@LionCog.listener("on_voice_state_update")
@@ -314,6 +442,9 @@ class VoiceTrackerCog(LionCog):
# If tracked state did not change, ignore event
return
bchannel = before.channel if before else None
achannel = after.channel if after else None
# Take tracking lock
async with self.tracking_lock:
# Fetch tracked member session state
@@ -334,7 +465,7 @@ class VoiceTrackerCog(LionCog):
"Voice event does not match session information! "
f"Member '{member.name}' <uid:{member.id}> "
f"of guild '{member.guild.name}' <gid:{member.guild.id}> "
f"left channel '#{before.channel.name}' <cid:{leaving}> "
f"left channel '{bchannel}' <cid:{leaving}> "
f"during voice session in channel <cid:{tstate.channelid}>!"
)
# Close (or cancel) active session
@@ -344,16 +475,13 @@ class VoiceTrackerCog(LionCog):
" because they left the channel."
)
await session.close()
elif (
leaving not in untracked and
not (before.channel.category_id and before.channel.category_id in untracked)
):
elif not self.is_untracked(bchannel):
# Leaving tracked channel without an active session?
logger.warning(
"Voice event does not match session information! "
f"Member '{member.name}' <uid:{member.id}> "
f"of guild '{member.guild.name}' <gid:{member.guild.id}> "
f"left tracked channel '#{before.channel.name}' <cid:{leaving}> "
f"left tracked channel '{bchannel}' <cid:{leaving}> "
f"with no matching voice session!"
)
@@ -365,14 +493,11 @@ class VoiceTrackerCog(LionCog):
"Voice event does not match session information! "
f"Member '{member.name}' <uid:{member.id}> "
f"of guild '{member.guild.name}' <gid:{member.guild.id}> "
f"joined channel '#{after.channel.name}' <cid:{joining}> "
f"joined channel '{achannel}' <cid:{joining}> "
f"during voice session in channel <cid:{tstate.channelid}>!"
)
await session.close()
if (
joining not in untracked and
not (after.channel.category_id and after.channel.category_id in untracked)
):
if not self.is_untracked(achannel):
# If the channel they are joining is tracked, schedule a session start for them
delay, start, expiry = await self._session_boundaries_for(member.guild.id, member.id)
hourly_rate = await self._calculate_rate(member.guild.id, member.id, astate)
@@ -380,10 +505,27 @@ class VoiceTrackerCog(LionCog):
logger.debug(
f"Scheduling voice session for member `{member.name}' <uid:{member.id}> "
f"in guild '{member.guild.name}' <gid: member.guild.id> "
f"in channel '{after.channel.name}' <cid: {after.channel.id}>. "
f"in channel '{achannel}' <cid: {achannel.id}>. "
f"Session will start at {start}, expire at {expiry}, and confirm in {delay}."
)
await session.schedule_start(delay, start, expiry, astate, hourly_rate)
t = self.bot.translator.t
lguild = await self.bot.core.lions.fetch_guild(member.guild.id)
lguild.log_event(
t(_p(
'eventlog|event:voice_session_start|title',
"Member Joined Tracked Voice Channel"
)),
t(_p(
'eventlog|event:voice_session_start|desc',
"{member} joined {channel}."
)).format(
member=member.mention, channel=achannel.mention,
),
start=discord.utils.format_dt(start, 'F'),
expiry=discord.utils.format_dt(expiry, 'R'),
)
elif session.activity:
# If the channelid did not change, the live state must have
# Recalculate the economy rate, and update the session
@@ -391,116 +533,24 @@ class VoiceTrackerCog(LionCog):
hourly_rate = await self._calculate_rate(member.guild.id, member.id, astate)
await session.update(new_state=astate, new_rate=hourly_rate)
@LionCog.listener("on_guild_setting_update_untracked_channels")
async def update_untracked_channels(self, guildid, setting):
"""
Close sessions in untracked channels, and recalculate previously untracked sessions
"""
@LionCog.listener("on_guildset_untracked_channels")
@LionCog.listener("on_guildset_hourly_reward")
@LionCog.listener("on_guildset_hourly_live_bonus")
@LionCog.listener("on_guildset_daily_voice_cap")
@LionCog.listener("on_guildset_timezone")
async def _event_refresh_guild(self, guildid: int, setting):
if not self.handle_events:
return
async with self.tracking_lock:
lguild = await self.bot.core.lions.fetch_guild(guildid)
guild = self.bot.get_guild(guildid)
if not guild:
# Left guild while waiting on lock
return
cap = lguild.config.get('daily_voice_cap').value
untracked = self.untracked_channels.get(guildid, [])
now = utc_now()
# Iterate through active sessions, close any that are in untracked channels
active = VoiceSession._active_sessions_.get(guildid, {})
for session in list(active.values()):
if session.state.channelid in untracked:
await session.close()
# Iterate through voice members, open new sessions if needed
expiries = {}
to_create = []
for channel in guild.voice_channels:
if channel.id in untracked:
continue
for member in channel.members:
if self.get_session(guildid, member.id).activity:
# Already have an active session for this member
continue
userid = member.id
state = TrackedVoiceState.from_voice_state(member.voice)
# TODO: Take into account tracked_today time?
# TODO: Make a per-guild refresh function to stay DRY
tomorrow = lguild.today + dt.timedelta(days=1)
expiry = now + dt.timedelta(seconds=cap)
if expiry > tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
expiries[(guildid, userid)] = expiry
hourly_rate = await self._calculate_rate(guildid, userid, state)
to_create.append((
guildid, userid,
state.channelid,
now, now,
state.stream, state.video,
hourly_rate
))
if to_create:
# Ensure LionMembers exist
await self.bot.core.lions.fetch_members(
*(item[:2] for item in to_create)
)
# Ensure TrackedChannels exist
cids = set((item[2], item[0]) for item in to_create)
await self.data.TrackedChannel.fetch_multiple(*cids)
# Create new sessions
rows = await self.data.VoiceSessionsOngoing.table.insert_many(
('guildid', 'userid', 'channelid', 'start_time', 'last_update', 'live_stream',
'live_video', 'hourly_coins'),
*to_create
).with_adapter(self.data.VoiceSessionsOngoing._make_rows)
for row in rows:
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
logger.info(
f"Started {len(rows)} new voice sessions from voice members "
f"in previously untracked channels of guild '{guild.name}' <gid:{guildid}>."
)
@LionCog.listener("on_guild_setting_update_hourly_reward")
async def update_hourly_reward(self, guildid, setting):
if not self.handle_events:
return
async with self.tracking_lock:
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)
@LionCog.listener("on_guild_setting_update_hourly_live_bonus")
async def update_hourly_live_bonus(self, guildid, setting):
if not self.handle_events:
return
async with self.tracking_lock:
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)
@LionCog.listener("on_guild_setting_update_daily_voice_cap")
async def update_daily_voice_cap(self, guildid, setting):
# TODO: Guild daily_voice_cap setting triggers session expiry recalculation for all sessions
...
@LionCog.listener("on_guild_setting_update_timezone")
@log_wrap(action='Voice Track')
@log_wrap(action='Timezone Update')
async def update_timezone(self, guildid, setting):
# TODO: Guild timezone setting triggers studied_today cache rebuild
logger.info("Received dispatch event for timezone change!")
guild = self.bot.get_guild(guildid)
if guild is None:
logger.warning(
f"Voice tracker discarding '{setting.setting_id}' event for unknown guild <gid: {guildid}>."
)
else:
logger.debug(
f"Voice tracker handling '{setting.setting_id}' event for guild <gid: {guildid}>."
)
await self.refresh_guild_sessions(guild)
async def _calculate_rate(self, guildid, userid, state):
"""
@@ -522,7 +572,7 @@ class VoiceTrackerCog(LionCog):
return hourly_rate
async def _session_boundaries_for(self, guildid: int, userid: int) -> tuple[int, dt.datetime, dt.datetime]:
async def _session_boundaries_for(self, guildid: int, userid: int) -> tuple[float, dt.datetime, dt.datetime]:
"""
Compute when the next session for this member should start and expire.
@@ -539,7 +589,7 @@ class VoiceTrackerCog(LionCog):
"""
lguild = await self.bot.core.lions.fetch_guild(guildid)
now = lguild.now
tomorrow = now + dt.timedelta(days=1)
tomorrow = lguild.today + dt.timedelta(days=1)
studied_today = await self.fetch_tracked_today(guildid, userid)
cap = lguild.config.get('daily_voice_cap').value
@@ -551,7 +601,8 @@ class VoiceTrackerCog(LionCog):
start_time = now
delay = 20
expiry = start_time + dt.timedelta(seconds=cap)
remaining = max(cap - studied_today, 0)
expiry = start_time + dt.timedelta(seconds=remaining)
if expiry >= tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
@@ -574,61 +625,9 @@ class VoiceTrackerCog(LionCog):
Initialise and start required new sessions from voice channel members when we join a guild.
"""
if not self.handle_events:
# Initialisation will take care of it for us
return
async with self.tracking_lock:
guildid = guild.id
lguild = await self.bot.core.lions.fetch_guild(guildid)
cap = lguild.config.get('daily_voice_cap').value
untracked = self.untracked_channels.get(guildid, [])
now = utc_now()
expiries = {}
to_create = []
for channel in guild.voice_channels:
if channel.id in untracked:
continue
for member in channel.members:
userid = member.id
state = TrackedVoiceState.from_voice_state(member.voice)
tomorrow = lguild.today + dt.timedelta(days=1)
expiry = now + dt.timedelta(seconds=cap)
if expiry > tomorrow:
expiry = tomorrow + dt.timedelta(seconds=cap)
expiries[(guildid, userid)] = expiry
hourly_rate = await self._calculate_rate(guildid, userid, state)
to_create.append((
guildid, userid,
state.channelid,
now, now,
state.stream, state.video,
hourly_rate
))
if to_create:
# Ensure LionMembers exist
await self.bot.core.lions.fetch_members(
*(item[:2] for item in to_create)
)
# Ensure TrackedChannels exist
cids = set((item[2], item[0]) for item in to_create)
await self.data.TrackedChannel.fetch_multiple(*cids)
# Create new sessions
rows = await self.data.VoiceSessionsOngoing.table.insert_many(
('guildid', 'userid', 'channelid', 'start_time', 'last_update', 'live_stream',
'live_video', 'hourly_coins'),
*to_create
).with_adapter(self.data.VoiceSessionsOngoing._make_rows)
for row in rows:
VoiceSession.from_ongoing(self.bot, row, expiries[(row.guildid, row.userid)])
logger.info(
f"Started {len(rows)} new voice sessions from voice members "
f"in new guild '{guild.name}' <gid:{guildid}>."
)
await self.refresh_guild_sessions(guild)
@LionCog.listener("on_guild_remove")
@log_wrap(action='Leave Guild Voice Sessions')
@@ -645,10 +644,7 @@ class VoiceTrackerCog(LionCog):
now = utc_now()
to_close = [] # (guildid, userid, _at)
for session in sessions.values():
if session.start_task is not None:
session.start_task.cancel()
if session.expiry_task is not None:
session.expiry_task.cancel()
session.cancel()
to_close.append((session.guildid, session.userid, now))
if to_close:
await self.data.VoiceSessionsOngoing.close_voice_sessions_at(*to_close)

View File

@@ -7,6 +7,7 @@ from data import RowModel, Registry, Table
from data.columns import Integer, String, Timestamp, Bool
from core.data import CoreData
from utils.lib import utc_now
class VoiceTrackerData(Registry):
@@ -108,11 +109,16 @@ class VoiceTrackerData(Registry):
video_duration = Integer()
stream_duration = Integer()
coins_earned = Integer()
last_update = Integer()
last_update = Timestamp()
live_stream = Bool()
live_video = Bool()
hourly_coins = Integer()
@property
def _total_coins_earned(self):
since = (utc_now() - self.last_update).total_seconds() / 3600
return self.coins_earned + since * self.hourly_coins
@classmethod
@log_wrap(action='close_voice_session')
async def close_study_session_at(cls, guildid: int, userid: int, _at: dt.datetime) -> int:
@@ -154,7 +160,7 @@ class VoiceTrackerData(Registry):
async def update_voice_session_at(
cls, guildid: int, userid: int, _at: dt.datetime,
stream: bool, video: bool, rate: float
) -> int:
):
async with cls._connector.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute(

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional, overload, Literal
from enum import IntEnum
from collections import defaultdict
import datetime as dt
@@ -12,7 +12,9 @@ from meta import LionBot
from data import WeakCache
from .data import VoiceTrackerData
from . import logger
from . import logger, babel
_p = babel._p
class TrackedVoiceState:
@@ -73,11 +75,14 @@ class VoiceSession:
'start_task', 'expiry_task',
'data', 'state', 'hourly_rate',
'_tag', '_start_time',
'lock',
'__weakref__'
)
_sessions_ = defaultdict(lambda: WeakCache(TTLCache(5000, ttl=60*60))) # Registry mapping
_active_sessions_ = defaultdict(dict) # Maintains strong references to active sessions
# Maintains strong references to active sessions
_active_sessions_: dict[int, dict[int, 'VoiceSession']] = defaultdict(dict)
def __init__(self, bot: LionBot, guildid: int, userid: int, data=None):
self.bot = bot
@@ -96,6 +101,17 @@ class VoiceSession:
self._tag = None
self._start_time = None
# Member session lock
# Ensures state changes are atomic and serialised
self.lock = asyncio.Lock()
def cancel(self):
if self.start_task is not None:
self.start_task.cancel()
if self.expiry_task is not None:
self.expiry_task.cancel()
self._active_sessions_[self.guildid].pop(self.userid, None)
@property
def tag(self) -> Optional[str]:
if self.data:
@@ -121,6 +137,16 @@ class VoiceSession:
else:
return SessionState.INACTIVE
@overload
@classmethod
def get(cls, bot: LionBot, guildid: int, userid: int, create: Literal[False]) -> Optional['VoiceSession']:
...
@overload
@classmethod
def get(cls, bot: LionBot, guildid: int, userid: int, create: Literal[True] = True) -> 'VoiceSession':
...
@classmethod
def get(cls, bot: LionBot, guildid: int, userid: int, create=True) -> Optional['VoiceSession']:
"""
@@ -149,11 +175,12 @@ class VoiceSession:
return self
async def set_tag(self, new_tag):
if self.activity is SessionState.INACTIVE:
raise ValueError("Cannot set tag on an inactive voice session.")
self._tag = new_tag
if self.data is not None:
await self.data.update(tag=new_tag)
async with self.lock:
if self.activity is SessionState.INACTIVE:
raise ValueError("Cannot set tag on an inactive voice session.")
self._tag = new_tag
if self.data is not None:
await self.data.update(tag=new_tag)
async def schedule_start(self, delay, start_time, expire_time, state, hourly_rate):
"""
@@ -167,6 +194,7 @@ class VoiceSession:
self.start_task = asyncio.create_task(self._start_after(delay, start_time))
self.schedule_expiry(expire_time)
self._active_sessions_[self.guildid][self.userid] = self
async def _start_after(self, delay: int, start_time: dt.datetime):
"""
@@ -174,36 +202,36 @@ class VoiceSession:
Creates the tracked_channel if required.
"""
self._active_sessions_[self.guildid][self.userid] = self
await asyncio.sleep(delay)
logger.debug(
f"Starting voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
# Create the lion if required
await self.bot.core.lions.fetch_member(self.guildid, self.userid)
async with self.lock:
logger.info(
f"Starting voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
# Create the lion if required
await self.bot.core.lions.fetch_member(self.guildid, self.userid)
# Create the tracked channel if required
await self.registry.TrackedChannel.fetch_or_create(
self.state.channelid, guildid=self.guildid, deleted=False
)
# Create the tracked channel if required
await self.registry.TrackedChannel.fetch_or_create(
self.state.channelid, guildid=self.guildid, deleted=False
)
# Insert an ongoing_session with the correct state, set data
state = self.state
self.data = await self.registry.VoiceSessionsOngoing.create(
guildid=self.guildid,
userid=self.userid,
channelid=state.channelid,
start_time=start_time,
last_update=start_time,
live_stream=state.stream,
live_video=state.video,
hourly_coins=self.hourly_rate,
tag=self._tag
)
self.bot.dispatch('voice_session_start', self.data)
self.start_task = None
# Insert an ongoing_session with the correct state, set data
state = self.state
self.data = await self.registry.VoiceSessionsOngoing.create(
guildid=self.guildid,
userid=self.userid,
channelid=state.channelid,
start_time=start_time,
last_update=start_time,
live_stream=state.stream,
live_video=state.video,
hourly_coins=self.hourly_rate,
tag=self._tag
)
self.bot.dispatch('voice_session_start', self.data)
self.start_task = None
def schedule_expiry(self, expire_time):
"""
@@ -217,18 +245,6 @@ class VoiceSession:
delay = (expire_time - utc_now()).total_seconds()
self.expiry_task = asyncio.create_task(self._expire_after(delay))
async def _expire_after(self, delay: int):
"""
Expire a session which has exceeded the daily voice cap.
"""
# TODO: Logging, and guild logging, and user notification (?)
await asyncio.sleep(delay)
logger.info(
f"Expiring voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
await self.close()
async def update(self, new_state: Optional[TrackedVoiceState] = None, new_rate: Optional[int] = None):
"""
Update the session state with the provided voice state or hourly rate.
@@ -254,10 +270,114 @@ class VoiceSession:
rate=self.hourly_rate
)
async def _expire_after(self, delay: int):
"""
Expire a session which has exceeded the daily voice cap.
"""
# TODO: Logging, and guild logging, and user notification (?)
await asyncio.sleep(delay)
logger.info(
f"Expiring voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
async with self.lock:
await self._close()
if self.activity:
t = self.bot.translator.t
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
if self.activity is SessionState.ONGOING and self.data is not None:
lguild.log_event(
t(_p(
'eventlog|event:voice_session_expired|title',
"Member Voice Session Expired"
)),
t(_p(
'eventlog|event:voice_session_expired|desc',
"{member}'s voice session in {channel} expired "
"because they reached the daily voice cap."
)).format(
member=f"<@{self.userid}>",
channel=f"<#{self.state.channelid}>",
),
start=discord.utils.format_dt(self.data.start_time),
coins_earned=int(self.data._total_coins_earned),
)
if self.start_task is not None:
self.start_task.cancel()
self.start_task = None
self.data = None
cog = self.bot.get_cog('VoiceTrackerCog')
delay, start, expiry = await cog._session_boundaries_for(self.guildid, self.userid)
hourly_rate = await cog._calculate_rate(self.guildid, self.userid, self.state)
self.hourly_rate = hourly_rate
self._start_time = start
self.start_task = asyncio.create_task(self._start_after(delay, start))
self.schedule_expiry(expiry)
async def close(self):
"""
Close the session, or cancel the pending session. Idempotent.
"""
async with self.lock:
await self._close()
if self.activity:
t = self.bot.translator.t
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
if self.activity is SessionState.ONGOING and self.data is not None:
lguild.log_event(
t(_p(
'eventlog|event:voice_session_closed|title',
"Member Voice Session Ended"
)),
t(_p(
'eventlog|event:voice_session_closed|desc',
"{member} completed their voice session in {channel}."
)).format(
member=f"<@{self.userid}>",
channel=f"<#{self.state.channelid}>",
),
start=discord.utils.format_dt(self.data.start_time),
coins_earned=int(self.data._total_coins_earned),
)
else:
lguild.log_event(
t(_p(
'eventlog|event:voice_session_cancelled|title',
"Member Voice Session Cancelled"
)),
t(_p(
'eventlog|event:voice_session_cancelled|desc',
"{member} left {channel} before their voice session started."
)).format(
member=f"<@{self.userid}>",
channel=f"<#{self.state.channelid}>",
),
)
if self.start_task is not None:
self.start_task.cancel()
self.start_task = None
if self.expiry_task is not None:
self.expiry_task.cancel()
self.expiry_task = None
self.data = None
self.state = None
self.hourly_rate = None
self._tag = None
self._start_time = None
# Always release strong reference to session (to allow garbage collection)
self._active_sessions_[self.guildid].pop(self.userid)
async def _close(self):
if self.activity is SessionState.ONGOING:
# End the ongoing session
now = utc_now()
@@ -273,18 +393,3 @@ class VoiceSession:
asyncio.create_task(rank_cog.on_voice_session_complete(
(self.guildid, self.userid, int((utc_now() - self.data.start_time).total_seconds()), 0)
))
if self.start_task is not None:
self.start_task.cancel()
self.start_task = None
if self.expiry_task is not None:
self.expiry_task.cancel()
self.expiry_task = None
self.data = None
self.state = None
self.hourly_rate = None
# Always release strong reference to session (to allow garbage collection)
self._active_sessions_[self.guildid].pop(self.userid)

View File

@@ -34,7 +34,7 @@ _p = babel._p
class VoiceTrackerSettings(SettingGroup):
class UntrackedChannels(ListData, ChannelListSetting):
setting_id = 'untracked_channels'
_event = 'guild_setting_update_untracked_channels'
_event = 'guildset_untracked_channels'
_set_cmd = 'configure voice_rewards'
_display_name = _p('guildset:untracked_channels', "untracked_channels")
@@ -111,7 +111,7 @@ class VoiceTrackerSettings(SettingGroup):
class HourlyReward(ModelData, IntegerSetting):
setting_id = 'hourly_reward'
_event = 'guild_setting_update_hourly_reward'
_event = 'on_guildset_hourly_reward'
_set_cmd = 'configure voice_rewards'
_display_name = _p('guildset:hourly_reward', "hourly_reward")
@@ -191,7 +191,7 @@ class VoiceTrackerSettings(SettingGroup):
Guild setting describing the per-hour LionCoin bonus given to "live" members during tracking.
"""
setting_id = 'hourly_live_bonus'
_event = 'guild_setting_update_hourly_live_bonus'
_event = 'on_guildset_hourly_live_bonus'
_set_cmd = 'configure voice_rewards'
_display_name = _p('guildset:hourly_live_bonus', "hourly_live_bonus")
@@ -242,7 +242,7 @@ class VoiceTrackerSettings(SettingGroup):
class DailyVoiceCap(ModelData, DurationSetting):
setting_id = 'daily_voice_cap'
_event = 'guild_setting_update_daily_voice_cap'
_event = 'on_guildset_daily_voice_cap'
_set_cmd = 'configure voice_rewards'
_display_name = _p('guildset:daily_voice_cap', "daily_voice_cap")
@@ -457,6 +457,9 @@ class VoiceTrackerConfigUI(ConfigUI):
@select(
cls=ChannelSelect,
placeholder="UNTRACKED_CHANNELS_PLACEHOLDER",
channel_types=[
discord.enums.ChannelType.voice, discord.enums.ChannelType.stage_voice, discord.enums.ChannelType.category
],
min_values=0, max_values=25
)
async def untracked_channels_menu(self, selection: discord.Interaction, selected):

View File

@@ -20,6 +20,7 @@ class MetaUtils(LionCog):
'cmd:page|desc',
"Jump to a given page of the ouput of a previous command in this channel."
),
with_app_command=False
)
async def page_group(self, ctx: LionContext):
"""

View File

@@ -765,7 +765,7 @@ class Timezoned:
Return the start of the current month in the object's timezone
"""
today = self.today
return today - datetime.timedelta(days=(today.day - 1))
return today.replace(day=1)
def replace_multiple(format_string, mapping):

View File

@@ -32,7 +32,7 @@ class TaskMonitor(Generic[Taskid]):
self.executor: Optional[Callable[[Taskid], Coroutine[Any, Any, None]]] = executor
self._wakeup: asyncio.Event = asyncio.Event()
self._monitor_task: Optional[self.Task] = None
self._monitor_task: Optional[asyncio.Task] = None
# Task data
self._tasklist: list[Taskid] = []
@@ -42,6 +42,19 @@ class TaskMonitor(Generic[Taskid]):
# And allows simpler external cancellation if required
self._running: dict[Taskid, asyncio.Future] = {}
def __repr__(self):
return (
"<"
f"{self.__class__.__name__}"
f" tasklist={len(self._tasklist)}"
f" taskmap={len(self._taskmap)}"
f" wakeup={self._wakeup.is_set()}"
f" bucket={self._bucket}"
f" running={len(self._running)}"
f" task={self._monitor_task}"
f">"
)
def set_tasks(self, *tasks: tuple[Taskid, int]) -> None:
"""
Similar to `schedule_tasks`, but wipe and reset the tasklist.

View File

@@ -69,12 +69,12 @@ class DurationTransformer(Transformer):
name=t(_p(
'util:Duration|acmpl|error',
"Cannot extract duration from \"{partial}\""
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
else:
choice = appcmds.Choice(
name=strfdur(duration, short=False, show_days=True),
name=strfdur(duration, short=False, show_days=True)[:100],
value=partial
)
return [choice]

View File

@@ -108,6 +108,9 @@ class ConfigUI(LeoUI):
# Filter out settings which don't have input fields
items = [item for item in items if item][:5]
strings = [item.value for item in items]
if not items:
raise ValueError("Cannot make Config edit modal with no editable instances.")
modal = ConfigEditor(*items, title=t(self.edit_modal_title))
@modal.submit_callback()

View File

@@ -307,17 +307,17 @@ class Pager(BasePager):
"Current: Page {page}/{total}"
)).format(page=num+1, total=total)
choices = [
appcmds.Choice(name=string, value=str(num+1))
appcmds.Choice(name=string[:100], value=str(num+1))
for num, string in sorted(page_choices.items(), key=lambda t: t[0])
]
else:
# Particularly support page names here
choices = [
appcmds.Choice(
name='> ' * (i == num) + t(_p(
name=('> ' * (i == num) + t(_p(
'cmd:page|acmpl|pager:Pager|choice:general',
"Page {page}"
)).format(page=i+1),
)).format(page=i+1))[:100],
value=str(i+1)
)
for i in range(0, total)
@@ -351,7 +351,7 @@ class Pager(BasePager):
name=t(_p(
'cmd:page|acmpl|pager:Page|choice:select',
"Selected: Page {page}/{total}"
)).format(page=page_num+1, total=total),
)).format(page=page_num+1, total=total)[:100],
value=str(page_num + 1)
)
return [choice, *choices]
@@ -361,7 +361,7 @@ class Pager(BasePager):
name=t(_p(
'cmd:page|acmpl|pager:Page|error:parse',
"No matching pages!"
)).format(page=page_num, total=total),
)).format(page=page_num, total=total)[:100],
value=partial
)
]