From eaa44ab43cbc1fc95a29c3566065439714146822 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 01:51:41 +0300 Subject: [PATCH 01/25] (voice): Rewrite initialise and refresh mechanism. --- src/meta/LionBot.py | 2 +- src/modules/member_admin/cog.py | 3 +- src/modules/pomodoro/cog.py | 2 +- src/modules/rooms/cog.py | 8 +- src/modules/statistics/data.py | 8 +- src/settings/ui.py | 2 +- src/tracking/voice/cog.py | 606 ++++++++++++++------------------ src/tracking/voice/data.py | 4 +- src/tracking/voice/session.py | 21 +- src/tracking/voice/settings.py | 8 +- src/utils/lib.py | 2 +- 11 files changed, 297 insertions(+), 369 deletions(-) diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index ca699ffb..7ee77493 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -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() diff --git a/src/modules/member_admin/cog.py b/src/modules/member_admin/cog.py index 6887ed6d..a707a3ea 100644 --- a/src/modules/member_admin/cog.py +++ b/src/modules/member_admin/cog.py @@ -227,7 +227,8 @@ class MemberAdminCog(LionCog): logger.info(f"Cleared persisting roles for guild 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( diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index 12a6b3ae..e4e659de 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -343,7 +343,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. """ diff --git a/src/modules/rooms/cog.py b/src/modules/rooms/cog.py index e4543cbd..4b2a6d70 100644 --- a/src/modules/rooms/cog.py +++ b/src/modules/rooms/cog.py @@ -173,14 +173,15 @@ class RoomCog(LionCog): # 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 +197,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: diff --git a/src/modules/statistics/data.py b/src/modules/statistics/data.py index 76ba4cf6..42062254 100644 --- a/src/modules/statistics/data.py +++ b/src/modules/statistics/data.py @@ -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. diff --git a/src/settings/ui.py b/src/settings/ui.py index 719fb7e1..e53a874c 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -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) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index d5ecb3c8..7dbd82fe 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -1,17 +1,16 @@ from typing import Optional import asyncio 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 utils.lib import utc_now from core.lion_guild import VoiceMode from wards import low_management_ward, moderator_ctxward @@ -44,6 +43,8 @@ class VoiceTrackerCog(LionCog): self.untracked_channels = self.settings.UntrackedChannels._cache + self.active_sessions = VoiceSession._active_sessions_ + async def cog_load(self): await self.data.init() @@ -71,7 +72,8 @@ class VoiceTrackerCog(LionCog): # Simultaneously! ... - def get_session(self, guildid, userid, **kwargs) -> VoiceSession: + # ----- Cog API ----- + def get_session(self, guildid, userid, **kwargs) -> Optional[VoiceSession]: """ Get the VoiceSession for the given member. @@ -91,6 +93,197 @@ 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 ") + + async with self.tracking_lock: + # TODO: Add a 'lock holder' attribute which is readable by the monitor + logger.debug(f"Voice state refresh for 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() + + # 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 guild.voice_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 .") + + # 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 . Beginning reload." + ) + + await self._load_sessions(states, ongoing) + logger.info( + f"Completed guild voice session reload for " + 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 +292,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 + 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") @@ -391,116 +446,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}' ." - ) - - @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 ." + ) + else: + logger.debug( + f"Voice tracker handling '{setting.setting_id}' event for guild ." + ) + await self.refresh_guild_sessions(guild) async def _calculate_rate(self, guildid, userid, state): """ @@ -522,7 +485,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 +502,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 @@ -552,7 +515,7 @@ class VoiceTrackerCog(LionCog): delay = 20 expiry = start_time + dt.timedelta(seconds=cap) - if expiry >= tomorrow: + if expiry > tomorrow: expiry = tomorrow + dt.timedelta(seconds=cap) return (delay, start_time, expiry) @@ -574,61 +537,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}' ." - ) + await self.refresh_guild_sessions(guild) @LionCog.listener("on_guild_remove") @log_wrap(action='Leave Guild Voice Sessions') @@ -645,10 +556,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) diff --git a/src/tracking/voice/data.py b/src/tracking/voice/data.py index c003a4a2..86c5e500 100644 --- a/src/tracking/voice/data.py +++ b/src/tracking/voice/data.py @@ -108,7 +108,7 @@ 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() @@ -154,7 +154,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( diff --git a/src/tracking/voice/session.py b/src/tracking/voice/session.py index 5f5766aa..fe018581 100644 --- a/src/tracking/voice/session.py +++ b/src/tracking/voice/session.py @@ -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 @@ -96,6 +96,13 @@ class VoiceSession: self._tag = None self._start_time = None + 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 +128,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']: """ @@ -167,6 +184,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,7 +192,6 @@ class VoiceSession: Creates the tracked_channel if required. """ - self._active_sessions_[self.guildid][self.userid] = self await asyncio.sleep(delay) logger.debug( diff --git a/src/tracking/voice/settings.py b/src/tracking/voice/settings.py index 4f4d7387..a74bd541 100644 --- a/src/tracking/voice/settings.py +++ b/src/tracking/voice/settings.py @@ -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") diff --git a/src/utils/lib.py b/src/utils/lib.py index 10babc85..98d60c57 100644 --- a/src/utils/lib.py +++ b/src/utils/lib.py @@ -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): From 0f3a3bd15b246375706e480c4aabab4aff39d936 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Fri, 6 Oct 2023 19:16:48 +1300 Subject: [PATCH 02/25] Reduce bot snapshot frequency - Shard snapshots now occur every 15 minutes instead of 2 minutes - Failed shard snapshots now retry after 1 minute instead of 10 seconds --- src/analytics/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/analytics/server.py b/src/analytics/server.py index 0fb6cab8..a5375c17 100644 --- a/src/analytics/server.py +++ b/src/analytics/server.py @@ -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']) From c4a9f9abf3a2b6e79a353f5b9f4e64a77c2bb665 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:11:52 +0300 Subject: [PATCH 03/25] fix(statistics): Guard against interaction expiry. --- src/modules/statistics/ui/base.py | 10 +++++++--- src/modules/statistics/ui/leaderboard.py | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/modules/statistics/ui/base.py b/src/modules/statistics/ui/base.py index bf451e12..d307da65 100644 --- a/src/modules/statistics/ui/base.py +++ b/src/modules/statistics/ui/base.py @@ -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): """ diff --git a/src/modules/statistics/ui/leaderboard.py b/src/modules/statistics/ui/leaderboard.py index bf2f9206..4e017a82 100644 --- a/src/modules/statistics/ui/leaderboard.py +++ b/src/modules/statistics/ui/leaderboard.py @@ -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, From f51bcdd6e24a2eff319a24ce2ad9a67836e910ef Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:14:23 +0300 Subject: [PATCH 04/25] fix(exec): Limit peer acmpl. --- src/modules/sysadmin/exec_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sysadmin/exec_cog.py b/src/modules/sysadmin/exec_cog.py index 351219c5..13acfa4d 100644 --- a/src/modules/sysadmin/exec_cog.py +++ b/src/modules/sysadmin/exec_cog.py @@ -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) From ad8463a6d774fd2e436a1282f8266b372af343d7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:22:52 +0300 Subject: [PATCH 05/25] fix(sessions): Fix notification typo. --- src/modules/schedule/core/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/schedule/core/session.py b/src/modules/schedule/core/session.py index 537b7840..4e2c84b4 100644 --- a/src/modules/schedule/core/session.py +++ b/src/modules/schedule/core/session.py @@ -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...' From 28e9df192cd641aa947b7c220e32c7e9a96cd4ce Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:23:16 +0300 Subject: [PATCH 06/25] tweak(settings): Default to disallowing Object. --- src/settings/setting_types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index f5c99c5b..a654f07f 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -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: From fa6bfe8b05ad969b083acca803ffa285cdb529c7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:27:09 +0300 Subject: [PATCH 07/25] fix(ranks): Refresh responds before waiting. --- src/modules/ranks/ui/overview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/ranks/ui/overview.py b/src/modules/ranks/ui/overview.py index 27cb77d1..da098666 100644 --- a/src/modules/ranks/ui/overview.py +++ b/src/modules/ranks/ui/overview.py @@ -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) From c30e8ac5496c003cce8f227eddfa5d005a249b36 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 10:34:59 +0300 Subject: [PATCH 08/25] fix(text): Fix typo in text stats calc. --- src/tracking/text/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tracking/text/data.py b/src/tracking/text/data.py index 95481da7..61486923 100644 --- a/src/tracking/text/data.py +++ b/src/tracking/text/data.py @@ -301,7 +301,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 +325,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: From 04b4050172065324f73015b6afc429c4960a1471 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 12:51:42 +0300 Subject: [PATCH 09/25] fix(timer): Monitor correct voice lock. --- src/modules/pomodoro/cog.py | 2 +- src/modules/pomodoro/timer.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/pomodoro/cog.py b/src/modules/pomodoro/cog.py index e4e659de..8a2d263c 100644 --- a/src/modules/pomodoro/cog.py +++ b/src/modules/pomodoro/cog.py @@ -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 diff --git a/src/modules/pomodoro/timer.py b/src/modules/pomodoro/timer.py index 810f2946..fa5185d2 100644 --- a/src/modules/pomodoro/timer.py +++ b/src/modules/pomodoro/timer.py @@ -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: From 1f7b90612b65eb03458c710575ce5b6cae84c5a3 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 12:52:11 +0300 Subject: [PATCH 10/25] fix(ranks): Add create permission check. --- src/modules/ranks/ui/overview.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/modules/ranks/ui/overview.py b/src/modules/ranks/ui/overview.py index da098666..9f87e78a 100644 --- a/src/modules/ranks/ui/overview.py +++ b/src/modules/ranks/ui/overview.py @@ -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 @@ -157,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 ) From 0a70d2d6680a41bea8a429fa7bacce6ce1688f1c Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 14:41:10 +0300 Subject: [PATCH 11/25] (timer): Add extra timeout to connect. --- src/modules/pomodoro/timer.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/modules/pomodoro/timer.py b/src/modules/pomodoro/timer.py index fa5185d2..8198206b 100644 --- a/src/modules/pomodoro/timer.py +++ b/src/modules/pomodoro/timer.py @@ -481,14 +481,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 @@ -515,13 +514,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 From c8937e60c303ecdbb0fb2ac71fdeaea9584961e2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 7 Oct 2023 23:47:04 +0300 Subject: [PATCH 12/25] fix(shop): Handle unusual error status. --- src/modules/shop/shops/colours.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modules/shop/shops/colours.py b/src/modules/shop/shops/colours.py index a2add0ed..35f5c040 100644 --- a/src/modules/shop/shops/colours.py +++ b/src/modules/shop/shops/colours.py @@ -446,7 +446,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 +469,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( From 5ac01d5cb208bacd52072be9795ca7a01b07659d Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 01:57:54 +0300 Subject: [PATCH 13/25] fix(voice): Avoid possible race condition. --- src/tracking/voice/session.py | 122 +++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/src/tracking/voice/session.py b/src/tracking/voice/session.py index fe018581..b75ad302 100644 --- a/src/tracking/voice/session.py +++ b/src/tracking/voice/session.py @@ -73,11 +73,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 +99,10 @@ 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() @@ -166,11 +173,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): """ @@ -194,33 +202,34 @@ class VoiceSession: """ await asyncio.sleep(delay) - logger.debug( - f"Starting voice session for member in guild " - f"and channel ." - ) - # 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 in guild " + f"and channel ." + ) + # 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): """ @@ -275,33 +284,36 @@ class VoiceSession: """ Close the session, or cancel the pending session. Idempotent. """ - if self.activity is SessionState.ONGOING: - # End the ongoing session - now = utc_now() - await self.data.close_study_session_at(self.guildid, self.userid, now) + async with self.lock: + if self.activity is SessionState.ONGOING: + # End the ongoing session + now = utc_now() + await self.data.close_study_session_at(self.guildid, self.userid, now) - # TODO: Something a bit saner/safer.. dispatch the finished session instead? - self.bot.dispatch('voice_session_end', self.data, now) + # TODO: Something a bit saner/safer.. dispatch the finished session instead? + self.bot.dispatch('voice_session_end', self.data, now) - # Rank update - # TODO: Change to broadcasted event? - rank_cog = self.bot.get_cog('RankCog') - if rank_cog is not None: - asyncio.create_task(rank_cog.on_voice_session_complete( - (self.guildid, self.userid, int((utc_now() - self.data.start_time).total_seconds()), 0) - )) + # Rank update + # TODO: Change to broadcasted event? + rank_cog = self.bot.get_cog('RankCog') + if rank_cog is not None: + 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.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 + 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.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) + # Always release strong reference to session (to allow garbage collection) + self._active_sessions_[self.guildid].pop(self.userid) From 4085846f553f35de6e470497a5a5373380e368e3 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 01:58:22 +0300 Subject: [PATCH 14/25] (voice): Improve logging. --- src/tracking/voice/cog.py | 91 +++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 7dbd82fe..f6dbec89 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -10,6 +10,7 @@ from data import Condition from meta import LionBot, LionCog, LionContext from meta.logger import log_wrap from meta.sharding import THIS_SHARD +from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now from core.lion_guild import VoiceMode @@ -34,6 +35,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 @@ -45,7 +47,75 @@ class VoiceTrackerCog(LionCog): 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=len(VoiceSession._sessions_), + 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 guild.voice_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) @@ -369,6 +439,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 @@ -389,7 +462,7 @@ class VoiceTrackerCog(LionCog): "Voice event does not match session information! " f"Member '{member.name}' " f"of guild '{member.guild.name}' " - f"left channel '#{before.channel.name}' " + f"left channel '{bchannel}' " f"during voice session in channel !" ) # Close (or cancel) active session @@ -399,16 +472,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}' " f"of guild '{member.guild.name}' " - f"left tracked channel '#{before.channel.name}' " + f"left tracked channel '{bchannel}' " f"with no matching voice session!" ) @@ -420,14 +490,11 @@ class VoiceTrackerCog(LionCog): "Voice event does not match session information! " f"Member '{member.name}' " f"of guild '{member.guild.name}' " - f"joined channel '#{after.channel.name}' " + f"joined channel '{achannel}' " f"during voice session in channel !" ) 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) @@ -435,7 +502,7 @@ class VoiceTrackerCog(LionCog): logger.debug( f"Scheduling voice session for member `{member.name}' " f"in guild '{member.guild.name}' " - f"in channel '{after.channel.name}' . " + f"in channel '{achannel}' . " f"Session will start at {start}, expire at {expiry}, and confirm in {delay}." ) await session.schedule_start(delay, start, expiry, astate, hourly_rate) From 7ae1c5cb51f3cea2ce306b594972dcfc0e4c9a80 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 07:30:20 +0300 Subject: [PATCH 15/25] fix(dashboard): Handle interaction timeout. --- src/modules/config/dashboard.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index 63e5c9b5..bc19d330 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -185,23 +185,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() From 7583682cfc8e9ead68ce217566ee5253176ac3bf Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 07:30:44 +0300 Subject: [PATCH 16/25] fix(tree): Correct logstack for acmpl. --- src/meta/LionTree.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/meta/LionTree.py b/src/meta/LionTree.py index 5969a01e..cecf3b6c 100644 --- a/src/meta/LionTree.py +++ b/src/meta/LionTree.py @@ -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}") From 7e82acd9f8f71afac4bfab29f6c139224ac9f05f Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 07:38:35 +0300 Subject: [PATCH 17/25] fix(tasklist): Fix ui exception typo. --- src/modules/tasklist/ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/tasklist/ui.py b/src/modules/tasklist/ui.py index 76f4837a..fc32042e 100644 --- a/src/modules/tasklist/ui.py +++ b/src/modules/tasklist/ui.py @@ -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) From 01909822913852cad21c2b44800a22ef51fb909e Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 09:05:20 +0300 Subject: [PATCH 18/25] (meta): Improve logging. --- src/core/lion_member.py | 14 ++++++++-- src/modules/rolemenus/cog.py | 53 ++++++++++++++++++++++++++++++++++-- src/modules/schedule/cog.py | 2 +- src/tracking/text/cog.py | 47 ++++++++++++++++++++++++++++++-- src/utils/monitor.py | 15 +++++++++- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/core/lion_member.py b/src/core/lion_member.py index fb944e76..4a7a5632 100644 --- a/src/core/lion_member.py +++ b/src/core/lion_member.py @@ -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,12 +107,16 @@ 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", , . " + f"Error: {repr(e)}", + ) else: # Remove the role from persistent role storage cog = self.bot.get_cog('MemberAdminCog') diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 9efef99a..3bcc6811 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -14,6 +14,7 @@ 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 meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel from utils.lib import utc_now, error_embed from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents from utils.transformers import DurationTransformer @@ -142,6 +143,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 +153,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 +199,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): """ @@ -262,7 +308,7 @@ class RoleMenuCog(LionCog): If the bot is no longer in the server, ignores the expiry. If the member is no longer in the server, removes the role from persisted roles, if applicable. """ - logger.debug(f"Expiring RoleMenu equipped role {equipid}") + logger.info(f"Expiring RoleMenu equipped role {equipid}") rows = await self.data.RoleMenuHistory.fetch_expiring_where(equipid=equipid) if rows: equip_row = rows[0] @@ -277,6 +323,7 @@ class RoleMenuCog(LionCog): await equip_row.update(removed_at=now) 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 ----- diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index c655a991..bcdaaff5 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -982,7 +982,7 @@ class ScheduleCog(LionCog): value=partial ) ) - return choices + return choices[:25] @schedule_cmd.autocomplete('cancel') async def schedule_cmd_cancel_acmpl(self, interaction: discord.Interaction, partial: str): diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index acfb6fe3..d517a118 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -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() @@ -202,9 +243,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') diff --git a/src/utils/monitor.py b/src/utils/monitor.py index 79ed8209..96aedeb7 100644 --- a/src/utils/monitor.py +++ b/src/utils/monitor.py @@ -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. From 397d488732e7d66c356483b4025868d10c1fe5cf Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 09:41:10 +0300 Subject: [PATCH 19/25] fix(babel): Fallback on empty locale. --- src/babel/translator.py | 7 +++++-- src/modules/pomodoro/timer.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/babel/translator.py b/src/babel/translator.py index fa832383..22c55383 100644 --- a/src/babel/translator.py +++ b/src/babel/translator.py @@ -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 diff --git a/src/modules/pomodoro/timer.py b/src/modules/pomodoro/timer.py index 8198206b..be7a54c1 100644 --- a/src/modules/pomodoro/timer.py +++ b/src/modules/pomodoro/timer.py @@ -780,7 +780,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. """ From 868bc710f135423fd70660f8cfcd0bfae9a4a708 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 10:59:58 +0300 Subject: [PATCH 20/25] fix(text): Ensure batch lions exist. --- src/tracking/text/cog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tracking/text/cog.py b/src/tracking/text/cog.py index d517a118..1d48a2ee 100644 --- a/src/tracking/text/cog.py +++ b/src/tracking/text/cog.py @@ -198,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 = [] From 4db7e5a943ed14a2292284e46d949e553f3f438c Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 11:22:29 +0300 Subject: [PATCH 21/25] fix(profile): Always use guild for profile gui. --- src/modules/statistics/ui/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/statistics/ui/profile.py b/src/modules/statistics/ui/profile.py index 9e93b402..5eafa727 100644 --- a/src/modules/statistics/ui/profile.py +++ b/src/modules/statistics/ui/profile.py @@ -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 From 91a3f595b5295f5f559327ca299b805709bff4f5 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 11:34:45 +0300 Subject: [PATCH 22/25] fix(video): Fix alert exists assumption. --- src/modules/video_channels/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/video_channels/cog.py b/src/modules/video_channels/cog.py index 421c28d7..7e6338ac 100644 --- a/src/modules/video_channels/cog.py +++ b/src/modules/video_channels/cog.py @@ -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( From 1b4cfd0453f24b189b8df6751617049bd70dc92e Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 12:00:32 +0300 Subject: [PATCH 23/25] fix: Protect all acmpl lengths. --- src/babel/cog.py | 4 ++-- src/modules/reminders/cog.py | 8 ++++---- src/modules/rolemenus/cog.py | 12 ++++++------ src/modules/schedule/cog.py | 22 +++++++++++----------- src/modules/shop/shops/colours.py | 2 +- src/modules/tasklist/cog.py | 12 ++++++------ src/settings/setting_types.py | 4 ++-- src/utils/transformers.py | 4 ++-- src/utils/ui/pagers.py | 10 +++++----- 9 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/babel/cog.py b/src/babel/cog.py index 0ca4bcd5..440db01c 100644 --- a/src/babel/cog.py +++ b/src/babel/cog.py @@ -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 ) ] diff --git a/src/modules/reminders/cog.py b/src/modules/reminders/cog.py index 03cc087b..3382179b 100644 --- a/src/modules/reminders/cog.py +++ b/src/modules/reminders/cog.py @@ -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] diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 3bcc6811..a95c18d2 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -534,7 +534,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: @@ -545,7 +545,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) @@ -569,7 +569,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 @@ -591,7 +591,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] @@ -611,7 +611,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) @@ -620,7 +620,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] diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index bcdaaff5..1208f562 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -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,7 +978,7 @@ 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 ) ) @@ -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) diff --git a/src/modules/shop/shops/colours.py b/src/modules/shop/shops/colours.py index 35f5c040..d43f531d 100644 --- a/src/modules/shop/shops/colours.py +++ b/src/modules/shop/shops/colours.py @@ -1095,7 +1095,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): diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index 6457de31..fc63ca90 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -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 ) ] diff --git a/src/settings/setting_types.py b/src/settings/setting_types.py index a654f07f..71239cfc 100644 --- a/src/settings/setting_types.py +++ b/src/settings/setting_types.py @@ -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) diff --git a/src/utils/transformers.py b/src/utils/transformers.py index f4dccba7..a19906e3 100644 --- a/src/utils/transformers.py +++ b/src/utils/transformers.py @@ -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] diff --git a/src/utils/ui/pagers.py b/src/utils/ui/pagers.py index 0737296e..83e5522f 100644 --- a/src/utils/ui/pagers.py +++ b/src/utils/ui/pagers.py @@ -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 ) ] From 30b3e4981100b90ac3e54238af228b37ee35b837 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 12:09:02 +0300 Subject: [PATCH 24/25] tweak(sys_admin): Move presence under leo grp. --- src/modules/sysadmin/presence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/modules/sysadmin/presence.py b/src/modules/sysadmin/presence.py index 3d4ae20e..3d746f80 100644 --- a/src/modules/sysadmin/presence.py +++ b/src/modules/sysadmin/presence.py @@ -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." ) From 5f713243a9cef371053f04677281df3e3c6e82f2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 8 Oct 2023 12:12:50 +0300 Subject: [PATCH 25/25] (utils): Deactivate page command. --- src/utils/cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/cog.py b/src/utils/cog.py index 8a1403cf..db47d60b 100644 --- a/src/utils/cog.py +++ b/src/utils/cog.py @@ -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): """