From eaa44ab43cbc1fc95a29c3566065439714146822 Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 6 Oct 2023 01:51:41 +0300 Subject: [PATCH 01/49] (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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] (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/49] 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/49] 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/49] (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/49] 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/49] 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/49] 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/49] (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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] (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): """ From 5476ba100287a2a302a114e4eda5c1e55e382b1e Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 08:09:57 +0300 Subject: [PATCH 26/49] fix(voice): Patch channel list and fix cache. Accounts for stage channels in initialisation. Clears _sessions_ cache when refreshing guild. Restrict `untracked_channels` selector to exclude text. --- src/tracking/voice/cog.py | 13 ++++++++----- src/tracking/voice/session.py | 2 ++ src/tracking/voice/settings.py | 3 +++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index f6dbec89..93d706fe 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -1,5 +1,6 @@ from typing import Optional import asyncio +import itertools import datetime as dt import discord @@ -71,7 +72,7 @@ class VoiceTrackerCog(LionCog): locked=0, actual=0, channels=0, - cached=len(VoiceSession._sessions_), + cached=sum(len(gsessions) for gsessions in VoiceSession._sessions_.values()), initial_event=self.initialised, lock=self.tracking_lock ) @@ -92,7 +93,7 @@ class VoiceTrackerCog(LionCog): data['channels'] = len(channels) for guild in self.bot.guilds: - for channel in guild.voice_channels: + 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: @@ -143,7 +144,7 @@ class VoiceTrackerCog(LionCog): ... # ----- Cog API ----- - def get_session(self, guildid, userid, **kwargs) -> Optional[VoiceSession]: + def get_session(self, guildid, userid, **kwargs): """ Get the VoiceSession for the given member. @@ -325,6 +326,8 @@ class VoiceTrackerCog(LionCog): 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) @@ -332,7 +335,7 @@ class VoiceTrackerCog(LionCog): # Read tracked voice states states = {} - for channel in guild.voice_channels: + 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: @@ -390,7 +393,7 @@ class VoiceTrackerCog(LionCog): # Read and save the tracked voice states of all visible voice channels states = {} for guild in self.bot.guilds: - for channel in guild.voice_channels: + 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: diff --git a/src/tracking/voice/session.py b/src/tracking/voice/session.py index b75ad302..37de1cdc 100644 --- a/src/tracking/voice/session.py +++ b/src/tracking/voice/session.py @@ -253,6 +253,8 @@ class VoiceSession: f"Expiring voice session for member in guild " f"and channel ." ) + # TODO: Would be better not to close the session and wipe the state + # Instead start a new PENDING session. await self.close() async def update(self, new_state: Optional[TrackedVoiceState] = None, new_rate: Optional[int] = None): diff --git a/src/tracking/voice/settings.py b/src/tracking/voice/settings.py index a74bd541..4d74a3da 100644 --- a/src/tracking/voice/settings.py +++ b/src/tracking/voice/settings.py @@ -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): From 50a1a9c8a12eff6f199add786ead9917323602b2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 11:27:20 +0300 Subject: [PATCH 27/49] logging: Greatly increase role remove logging. --- src/core/lion_member.py | 12 ++++++++++++ src/modules/rolemenus/cog.py | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/core/lion_member.py b/src/core/lion_member.py index 4a7a5632..3e5a2e25 100644 --- a/src/core/lion_member.py +++ b/src/core/lion_member.py @@ -117,8 +117,20 @@ class LionMember(Timezoned): f", , . " f"Error: {repr(e)}", ) + else: + if role not in member.roles: + logger.info( + f"Removed role from member in " + ) + else: + logger.error( + f"Tried to remove role " + f"from member in . " + "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 from absent lion in ") diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index a95c18d2..051cfb32 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -308,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.info(f"Expiring RoleMenu equipped role {equipid}") + logger.debug(f"Expiring RoleMenu equipped role {equipid}") rows = await self.data.RoleMenuHistory.fetch_expiring_where(equipid=equipid) if rows: equip_row = rows[0] @@ -319,8 +319,22 @@ class RoleMenuCog(LionCog): 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!") + 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." + ) + else: + logger.info(f"Could not expire {equipid} because the role was not found.") now = utc_now() 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.") @@ -351,7 +365,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', From d674dc4c8e8ce7f3387a34ef0f09eb2f4919e7ef Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 16:50:35 +0300 Subject: [PATCH 28/49] (stats): New xp display format. --- src/gui | 2 +- src/modules/statistics/graphics/stats.py | 49 +++++++++++++++++++----- src/tracking/text/data.py | 36 +++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/gui b/src/gui index 24e94d10..f2760218 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit 24e94d10e2ef2e34a6feb2bc8f9eca268260f512 +Subproject commit f2760218ef065f1cde53b801b184cfe02f24dff0 diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index ffa19bb0..5a093db1 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -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} diff --git a/src/tracking/text/data.py b/src/tracking/text/data.py index 61486923..ec75f197 100644 --- a/src/tracking/text/data.py +++ b/src/tracking/text/data.py @@ -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') From cfc9ea5ea9c15351c384e6fe2eea47bda3a0c005 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 18:23:10 +0300 Subject: [PATCH 29/49] feat(stats): New /profile command. --- src/modules/statistics/cog.py | 49 ++++++++++++++- src/modules/statistics/graphics/profile.py | 4 +- .../statistics/graphics/profilestats.py | 62 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/modules/statistics/graphics/profilestats.py diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index 899ed657..fd640004 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -8,14 +8,17 @@ 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 _p = babel._p @@ -43,7 +46,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 +56,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( diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 759a974b..42798cf7 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -17,11 +17,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 diff --git a/src/modules/statistics/graphics/profilestats.py b/src/modules/statistics/graphics/profilestats.py new file mode 100644 index 00000000..3296cbea --- /dev/null +++ b/src/modules/statistics/graphics/profilestats.py @@ -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 From 01a0f337b70b407211945230890bc9b6b6ae18d7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 21:49:20 +0300 Subject: [PATCH 30/49] feat(statistics): Implement achievements. --- src/core/data.py | 3 + src/modules/statistics/achievements.py | 458 +++++++++++++++++++++ src/modules/statistics/cog.py | 33 ++ src/modules/statistics/graphics/profile.py | 6 +- 4 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 src/modules/statistics/achievements.py diff --git a/src/core/data.py b/src/core/data.py index 9870f671..177fccd6 100644 --- a/src/core/data.py +++ b/src/core/data.py @@ -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') diff --git a/src/modules/statistics/achievements.py b/src/modules/statistics/achievements.py new file mode 100644 index 00000000..87d3a928 --- /dev/null +++ b/src/modules/statistics/achievements.py @@ -0,0 +1,458 @@ +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']) + + +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) + + +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']) + + +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) + + +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) + + +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'] 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 lion.today - dt.timedelta(days=1) + + # 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 diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index fd640004..fa3d923c 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -19,6 +19,7 @@ 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 @@ -152,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) diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 42798cf7..38fac587 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -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 @@ -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 From be4fb5c7e25f3f3155073e6c838c5fef3a2d4411 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 10 Oct 2023 07:55:04 +0300 Subject: [PATCH 31/49] (schema): Update schema to v13. --- data/schema.sql | 1029 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 710 insertions(+), 319 deletions(-) diff --git a/data/schema.sql b/data/schema.sql index 6c794b20..26a0c607 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -17,6 +17,32 @@ $$ language 'plpgsql'; -- }}} -- App metadata {{{ +CREATE TABLE AppData( + appid TEXT PRIMARY KEY, + last_study_badge_scan TIMESTAMP +); + +CREATE TABLE AppConfig( + appid TEXT, + key TEXT, + value TEXT, + PRIMARY KEY(appid, key) +); + +CREATE TABLE global_user_blacklist( + userid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE global_guild_blacklist( + guildid BIGINT PRIMARY KEY, + ownerid BIGINT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + CREATE TABLE app_config( appname TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -57,115 +83,32 @@ CREATE TABLE bot_config_presence( activity_name Text ); -CREATE TABLE AppData( - appid TEXT PRIMARY KEY, - last_study_badge_scan TIMESTAMP -); - -CREATE TABLE AppConfig( - appid TEXT, - key TEXT, - value TEXT, - PRIMARY KEY(appid, key) -); - -CREATE TABLE global_user_blacklist( - userid BIGINT PRIMARY KEY, - ownerid BIGINT NOT NULL, - reason TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE global_guild_blacklist( - guildid BIGINT PRIMARY KEY, - ownerid BIGINT NOT NULL, - reason TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() -); -- }}} --- Analytics data {{{ -CREATE SCHEMA "analytics"; - -CREATE TABLE analytics.snapshots( - snapshotid SERIAL PRIMARY KEY, - appname TEXT NOT NULL REFERENCES bot_config (appname), - guild_count INTEGER NOT NULL, - member_count INTEGER NOT NULL, - user_count INTEGER NOT NULL, - in_voice INTEGER NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') -); - - -CREATE TABLE analytics.events( - eventid SERIAL PRIMARY KEY, - appname TEXT NOT NULL REFERENCES bot_config (appname), - ctxid BIGINT, - guildid BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') -); - -CREATE TYPE analytics.CommandStatus AS ENUM( - 'COMPLETED', - 'CANCELLED' - 'FAILED' -); - -CREATE TABLE analytics.commands( - cmdname TEXT NOT NULL, - cogname TEXT, - userid BIGINT NOT NULL, - status analytics.CommandStatus NOT NULL, - error TEXT, - execution_time REAL NOT NULL -) INHERITS (analytics.events); - - -CREATE TYPE analytics.GuildAction AS ENUM( - 'JOINED', - 'LEFT' -); - -CREATE TABLE analytics.guilds( - guildid BIGINT NOT NULL, - action analytics.GuildAction NOT NULL -) INHERITS (analytics.events); - - -CREATE TYPE analytics.VoiceAction AS ENUM( - 'JOINED', - 'LEFT' -); - -CREATE TABLE analytics.voice_sessions( - userid BIGINT NOT NULL, - action analytics.VoiceAction NOT NULL -) INHERITS (analytics.events); - -CREATE TABLE analytics.gui_renders( - cardname TEXT NOT NULL, - duration INTEGER NOT NULL -) INHERITS (analytics.events); ---- }}} - -- User configuration data {{{ CREATE TABLE user_config( userid BIGINT PRIMARY KEY, timezone TEXT, + name TEXT, topgg_vote_reminder BOOLEAN, avatar_hash TEXT, - name TEXT, + API_timestamp BIGINT, + gems INTEGER DEFAULT 0, first_seen TIMESTAMPTZ DEFAULT now(), last_seen TIMESTAMPTZ, - API_timestamp BIGINT, locale_hint TEXT, locale TEXT, - gems INTEGER DEFAULT 0 + show_global_stats BOOLEAN ); -- }}} -- Guild configuration data {{{ +CREATE TYPE RankType AS ENUM( + 'XP', + 'VOICE', + 'MESSAGE' +); + CREATE TABLE guild_config( guildid BIGINT PRIMARY KEY, admin_role BIGINT, @@ -201,10 +144,20 @@ CREATE TABLE guild_config( daily_study_cap INTEGER, pomodoro_channel BIGINT, name TEXT, - first_joined_at TIMESTAMPTZ DEFAULT now(), - left_at TIMESTAMPTZ, locale TEXT, - force_locale BOOLEAN + force_locale BOOLEAN, + allow_transfers BOOLEAN, + season_start TIMESTAMPTZ, + xp_per_period INTEGER, + xp_per_centiword INTEGER, + coins_per_centixp INTEGER, + timezone TEXT, + rank_type RankType, + rank_channel BIGINT, + dm_ranks BOOLEAN, + renting_visible BOOLEAN, + first_joined_at TIMESTAMPTZ DEFAULT now(), + left_at TIMESTAMPTZ ); CREATE TABLE ignored_members( @@ -236,6 +189,84 @@ CREATE TABLE bot_autoroles( roleid BIGINT NOT NULL ); CREATE INDEX bot_autoroles_guilds ON bot_autoroles (guildid); + +CREATE TYPE StatisticType AS ENUM( + 'VOICE', + 'TEXT', + 'ANKI' +); +CREATE TABLE visible_statistics( + guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE, + stat_type StatisticType NOT NULL +); +CREATE INDEX visible_statistics_guilds ON visible_statistics (guildid); + +CREATE TABLE channel_webhooks( + channelid BIGINT NOT NULL PRIMARY KEY, + webhookid BIGINT NOT NULL, + token TEXT NOT NULL +); +-- }}} + +-- Economy Data {{{ +CREATE TYPE CoinTransactionType AS ENUM( + 'REFUND', + 'TRANSFER', + 'SHOP_PURCHASE', + 'VOICE_SESSION', + 'TEXT_SESSION', + 'ADMIN', + 'TASKS', + 'SCHEDULE_BOOK', + 'SCHEDULE_REWARD', + 'OTHER' +); + + +CREATE TABLE coin_transactions( + transactionid SERIAL PRIMARY KEY, + transactiontype CoinTransactionType NOT NULL, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + actorid BIGINT NOT NULL, + amount INTEGER NOT NULL, + bonus INTEGER NOT NULL DEFAULT 0, + from_account BIGINT, + to_account BIGINT, + refunds INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') +); +CREATE INDEX coin_transaction_guilds ON coin_transactions (guildid); + +CREATE TABLE coin_transactions_tasks( + transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE, + count INTEGER NOT NULL +); + +CREATE TYPE EconAdminTarget AS ENUM( + 'ROLE', + 'USER', + 'GUILD' +); + +CREATE TYPE EconAdminAction AS ENUM( + 'SET', + 'ADD' +); + +CREATE TABLE economy_admin_actions( + actionid SERIAL PRIMARY KEY, + target_type EconAdminTarget NOT NULL, + action_type EconAdminAction NOT NULL, + targetid INTEGER NOT NULL, + amount INTEGER NOT NULL +); + +CREATE TABLE coin_transactions_admin_actions( + actionid INTEGER NOT NULL REFERENCES economy_admin_actions (actionid), + transactionid INTEGER NOT NULL REFERENCES coin_transactions (transactionid), + PRIMARY KEY (actionid, transactionid) +); +CREATE INDEX coin_transactions_admin_actions_transactionid ON coin_transactions_admin_actions (transactionid); -- }}} -- Workout data {{{ @@ -259,7 +290,7 @@ CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid); -- Tasklist data {{{ CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, - userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE, + userid BIGINT NOT NULL, content TEXT NOT NULL, rewarded BOOL DEFAULT FALSE, deleted_at TIMESTAMPTZ, @@ -268,31 +299,61 @@ CREATE TABLE tasklist( last_updated_at TIMESTAMPTZ ); CREATE INDEX tasklist_users ON tasklist (userid); +ALTER TABLE tasklist + ADD CONSTRAINT fk_tasklist_users + FOREIGN KEY (userid) + REFERENCES user_config (userid) + ON DELETE CASCADE + NOT VALID; +ALTER TABLE tasklist + ADD COLUMN parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL; CREATE TABLE tasklist_channels( - guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + guildid BIGINT NOT NULL, channelid BIGINT NOT NULL ); CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid); +ALTER TABLE tasklist_channels + ADD CONSTRAINT fk_tasklist_channels_guilds + FOREIGN KEY (guildid) + REFERENCES guild_config (guildid) + ON DELETE CASCADE + NOT VALID; + +CREATE TABLE tasklist_reward_history( + userid BIGINT NOT NULL, + reward_time TIMESTAMP DEFAULT (now() at time zone 'utc'), + reward_count INTEGER +); +CREATE INDEX tasklist_reward_history_users ON tasklist_reward_history (userid, reward_time); -- }}} -- Reminder data {{{ CREATE TABLE reminders( - reminderid SERIAL PRIMARY KEY, - userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE, - remind_at TIMESTAMP NOT NULL, - content TEXT NOT NULL, - message_link TEXT, - interval INTEGER, - created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), - failed BOOLEAN, - title TEXT, - footer TEXT + reminderid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config(userid) ON DELETE CASCADE, + remind_at TIMESTAMPTZ NOT NULL, + content TEXT NOT NULL, + message_link TEXT, + interval INTEGER, + failed BOOLEAN, + created_at TIMESTAMPTZ DEFAULT now(), + title TEXT, + footer TEXT ); CREATE INDEX reminder_users ON reminders (userid); -- }}} --- Study tracking data {{{ +-- Voice tracking data {{{ +CREATE TABLE tracked_channels( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL, + deleted BOOLEAN DEFAULT FALSE, + _timestamp TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'utc'), + FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE +); +CREATE INDEX tracked_channels_guilds ON tracked_channels (guildid); + CREATE TABLE untracked_channels( guildid BIGINT NOT NULL, channelid BIGINT NOT NULL @@ -332,20 +393,24 @@ CREATE TABLE shop_items( ); CREATE INDEX guild_shop_items ON shop_items (guildid); +CREATE TABLE coin_transactions_shop( + transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE, + itemid INTEGER NOT NULL REFERENCES shop_items (itemid) ON DELETE CASCADE +); + CREATE TABLE shop_items_colour_roles( itemid INTEGER PRIMARY KEY REFERENCES shop_items(itemid) ON DELETE CASCADE, roleid BIGINT NOT NULL ); CREATE TABLE member_inventory( - inventoryid SERIAL PRIMARY KEY, + inventoryid SERiAL PRIMARY KEY, + transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, guildid BIGINT NOT NULL, userid BIGINT NOT NULL, - first_joined TIMESTAMPTZ DEFAULT now(), - last_left TIMESTAMPTZ, - transactionid INTEGER REFERENCES coin_transactions(transactionid) ON DELETE SET NULL, itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE ); + CREATE INDEX member_inventory_members ON member_inventory(guildid, userid); @@ -358,6 +423,25 @@ CREATE VIEW shop_item_info AS LEFT JOIN shop_items_colour_roles USING (itemid) ORDER BY itemid ASC; + +CREATE VIEW member_inventory_info AS + SELECT + inv.inventoryid AS inventoryid, + inv.guildid AS guildid, + inv.userid AS userid, + inv.transactionid AS transactionid, + items.itemid AS itemid, + items.item_type AS item_type, + items.price AS price, + items.purchasable AS purchasable, + items.deleted AS deleted, + items.guild_itemid AS guild_itemid, + items.roleid AS roleid + FROM + member_inventory inv + LEFT JOIN shop_item_info items USING (itemid) + ORDER BY itemid ASC; + /* -- Shop config, not implemented CREATE TABLE guild_shop_config( @@ -376,6 +460,14 @@ CREATE TABLE video_channels( ); CREATE INDEX video_channels_guilds ON video_channels (guildid); +CREATE TABLE video_exempt_roles( + guildid BIGINT NOT NULL, + roleid BIGINT NOT NULL, + _timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (guildid, roleid) +); + CREATE TYPE TicketType AS ENUM ( 'NOTE', 'STUDY_BAN', @@ -517,8 +609,8 @@ CREATE INDEX studyban_durations_guilds ON studyban_durations (guildid); -- Member configuration and stored data {{{ CREATE TABLE members( - guildid BIGINT REFERENCES guild_config ON DELETE CASCADE, - userid BIGINT ON DELETE CASCADE, + guildid BIGINT, + userid BIGINT, tracked_time INTEGER DEFAULT 0, coins INTEGER DEFAULT 0, workout_count INTEGER DEFAULT 0, @@ -527,6 +619,8 @@ CREATE TABLE members( last_study_badgeid INTEGER REFERENCES study_badges ON DELETE SET NULL, video_warned BOOLEAN DEFAULT FALSE, display_name TEXT, + first_joined TIMESTAMPTZ DEFAULT now(), + last_left TIMESTAMPTZ, _timestamp TIMESTAMP DEFAULT (now() at time zone 'utc'), PRIMARY KEY(guildid, userid) ); @@ -535,6 +629,81 @@ CREATE INDEX member_timestamps ON members (_timestamp); CREATE TRIGGER update_members_timstamp BEFORE UPDATE ON members FOR EACH ROW EXECUTE PROCEDURE update_timestamp_column(); + +ALTER TABLE members + ADD CONSTRAINT fk_members_users FOREIGN KEY (userid) REFERENCES user_config (userid) ON DELETE CASCADE NOT VALID; +ALTER TABLE members + ADD CONSTRAINT fk_members_guilds FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE NOT VALID; +-- }}} + +-- Message tracking and statistics {{{ +CREATE TYPE ExperienceType AS ENUM( + 'VOICE_XP', + 'TEXT_XP', + 'QUEST_XP', -- Custom guild quests + 'ACHIEVEMENT_XP', -- Individual tracked achievements + 'BONUS_XP' -- Manually adjusted XP +); + +CREATE TABLE member_experience( + member_expid BIGSERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'), + amount INTEGER NOT NULL, + exp_type ExperienceType NOT NULL, + transactionid INTEGER REFERENCES coin_transactions ON DELETE SET NULL, + FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE +); +CREATE INDEX member_experience_members ON member_experience (guildid, userid, earned_at); +CREATE INDEX member_experience_guilds ON member_experience (guildid, earned_at); + +CREATE TABLE user_experience( + user_expid BIGSERIAL PRIMARY KEY, + userid BIGINT NOT NULL, + earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'), + amount INTEGER NOT NULL, + exp_type ExperienceType NOT NULL, + FOREIGN KEY (userid) REFERENCES user_config ON DELETE CASCADE +); +CREATE INDEX user_experience_users ON user_experience (userid, earned_at); + + +CREATE TABLE bot_config_experience_rates( + appname TEXT PRIMARY KEY REFERENCES bot_config(appname) ON DELETE CASCADE, + period_length INTEGER, + xp_per_period INTEGER, + xp_per_centiword INTEGER +); + +CREATE TABLE text_sessions( + sessionid BIGSERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + duration INTEGER NOT NULL, + messages INTEGER NOT NULL, + words INTEGER NOT NULL, + periods INTEGER NOT NULL, + user_expid BIGINT REFERENCES user_experience, + member_expid BIGINT REFERENCES member_experience, + end_time TIMESTAMP GENERATED ALWAYS AS + ((start_time AT TIME ZONE 'UTC') + duration * interval '1 second') + STORED, + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX text_sessions_members ON text_sessions (guildid, userid); +CREATE INDEX text_sessions_start_time ON text_sessions (start_time); +CREATE INDEX text_sessions_end_time ON text_sessions (end_time); + +CREATE TABLE untracked_text_channels( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL, + _timestamp TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'utc'), + FOREIGN KEY (guildid) REFERENCES guild_config (guildid) ON DELETE CASCADE +); +CREATE INDEX untracked_text_channels_guilds ON untracked_text_channels (guildid); + -- }}} -- Study Session Data {{{ @@ -546,279 +715,401 @@ CREATE TYPE SessionChannelType AS ENUM ( ); -CREATE TABLE session_history( +CREATE TABLE voice_sessions( sessionid SERIAL PRIMARY KEY, guildid BIGINT NOT NULL, userid BIGINT NOT NULL, channelid BIGINT, - channel_type SessionChannelType, rating INTEGER, tag TEXT, start_time TIMESTAMPTZ NOT NULL, duration INTEGER NOT NULL, - coins_earned INTEGER NOT NULL, live_duration INTEGER DEFAULT 0, stream_duration INTEGER DEFAULT 0, video_duration INTEGER DEFAULT 0, + transactionid INTEGER REFERENCES coin_transactions (transactionid) ON UPDATE CASCADE ON DELETE CASCADE, FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE ); -CREATE INDEX session_history_members ON session_history (guildid, userid, start_time); +CREATE INDEX voice_session_members ON voice_sessions (guildid, userid, start_time); +CREATE INDEX voice_session_guild_time ON voice_sessions USING BTREE (guildid, start_time); +CREATE INDEX voice_session_user_time ON voice_sessions USING BTREE (userid, start_time); +ALTER TABLE voice_sessions ADD FOREIGN KEY (channelid) REFERENCES tracked_channels (channelid); -CREATE TABLE current_sessions( +CREATE TABLE voice_sessions_ongoing( guildid BIGINT NOT NULL, userid BIGINT NOT NULL, - channelid BIGINT, - channel_type SessionChannelType, + channelid BIGINT REFERENCES tracked_channels (channelid), rating INTEGER, tag TEXT, - start_time TIMESTAMPTZ DEFAULT now(), - live_duration INTEGER DEFAULT 0, - live_start TIMESTAMPTZ, - stream_duration INTEGER DEFAULT 0, - stream_start TIMESTAMPTZ, - video_duration INTEGER DEFAULT 0, - video_start TIMESTAMPTZ, - hourly_coins INTEGER NOT NULL, - hourly_live_coins INTEGER NOT NULL, + start_time TIMESTAMPTZ DEFAULT (now() AT TIME ZONE 'UTC'), + live_duration INTEGER NOT NULL DEFAULT 0, + video_duration INTEGER NOT NULL DEFAULT 0, + stream_duration INTEGER NOT NULL DEFAULT 0, + coins_earned INTEGER NOT NULL DEFAULT 0, + last_update TIMESTAMPTZ DEFAULT (now() AT TIME ZONE 'UTC'), + live_stream BOOLEAN NOT NULL DEFAULT FALSE, + live_video BOOLEAN NOT NULL DEFAULT FALSE, + hourly_coins FLOAT NOT NULL DEFAULT 0, FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX current_session_members ON current_sessions (guildid, userid); +CREATE UNIQUE INDEX voice_sessions_ongoing_members ON voice_sessions_ongoing (guildid, userid); +CREATE FUNCTION close_study_session_at(_guildid BIGINT, _userid BIGINT, _now TIMESTAMPTZ) + RETURNS SETOF members +AS $$ + BEGIN + RETURN QUERY + WITH + voice_session AS ( + DELETE FROM voice_sessions_ongoing + WHERE guildid=_guildid AND userid=_userid + RETURNING + channelid, rating, tag, start_time, + EXTRACT(EPOCH FROM (_now - start_time)) AS total_duration, + ( + CASE WHEN live_stream + THEN stream_duration + EXTRACT(EPOCH FROM (_now - last_update)) + ELSE stream_duration + END + ) AS stream_duration, + ( + CASE WHEN live_video + THEN video_duration + EXTRACT(EPOCH FROM (_now - last_update)) + ELSE video_duration + END + ) AS video_duration, + ( + CASE WHEN live_stream OR live_video + THEN live_duration + EXTRACT(EPOCH FROM (_now - last_update)) + ELSE live_duration + END + ) AS live_duration, + ( + coins_earned + LEAST((EXTRACT(EPOCH FROM (_now - last_update)) * hourly_coins) / 3600, 2147483647) + ) AS coins_earned + ), + economy_transaction AS ( + INSERT INTO coin_transactions ( + guildid, actorid, + from_account, to_account, + amount, bonus, transactiontype + ) SELECT + _guildid, 0, + NULL, _userid, + voice_session.coins_earned, 0, 'VOICE_SESSION' + FROM voice_session + RETURNING + transactionid + ), + saved_session AS ( + INSERT INTO voice_sessions ( + guildid, userid, channelid, + rating, tag, + start_time, duration, live_duration, stream_duration, video_duration, + transactionid + ) SELECT + _guildid, _userid, voice_session.channelid, + voice_session.rating, voice_session.tag, + voice_session.start_time, voice_session.total_duration, voice_session.live_duration, + voice_session.stream_duration, voice_session.video_duration, + economy_transaction.transactionid + FROM voice_session, economy_transaction + RETURNING * + ) + UPDATE members + SET + coins=LEAST(coins::BIGINT + voice_session.coins_earned::BIGINT, 2147483647) + FROM + voice_session + WHERE + members.guildid=_guildid AND members.userid=_userid + RETURNING members.*; + END; +$$ LANGUAGE PLPGSQL; -CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) +CREATE OR REPLACE FUNCTION update_voice_session( + _guildid BIGINT, _userid BIGINT, _at TIMESTAMPTZ, _live_stream BOOLEAN, _live_video BOOLEAN, _hourly_coins FLOAT +) RETURNS SETOF voice_sessions_ongoing AS $$ + BEGIN + RETURN QUERY + UPDATE + voice_sessions_ongoing + SET + stream_duration = ( + CASE WHEN live_stream + THEN stream_duration + EXTRACT(EPOCH FROM (_at - last_update)) + ELSE stream_duration + END + ), + video_duration = ( + CASE WHEN live_video + THEN video_duration + EXTRACT(EPOCH FROM (_at - last_update)) + ELSE video_duration + END + ), + live_duration = ( + CASE WHEN live_stream OR live_video + THEN live_duration + EXTRACT(EPOCH FROM (_at - last_update)) + ELSE live_duration + END + ), + coins_earned = ( + coins_earned + LEAST((EXTRACT(EPOCH FROM (_at - last_update)) * hourly_coins) / 3600, 2147483647) + ), + last_update = _at, + live_stream = _live_stream, + live_video = _live_video, + hourly_coins = hourly_coins + WHERE + guildid = _guildid + AND + userid = _userid + RETURNING *; + END; +$$ LANGUAGE PLPGSQL; + +-- Function to retouch session? Or handle in application? +-- Function to finish session? Or handle in application? +-- Does database function make transaction, or application? + +CREATE VIEW voice_sessions_combined AS + SELECT + userid, + guildid, + start_time, + duration, + (start_time + duration * interval '1 second') AS end_time + FROM voice_sessions + UNION ALL + SELECT + userid, + guildid, + start_time, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, + NOW() AS end_time + FROM voice_sessions_ongoing; + +CREATE FUNCTION study_time_between(_guildid BIGINT, _userid BIGINT, _start TIMESTAMPTZ, _end TIMESTAMPTZ) RETURNS INTEGER AS $$ BEGIN RETURN ( SELECT - SUM( - CASE - WHEN start_time >= _timestamp THEN duration - ELSE EXTRACT(EPOCH FROM (end_time - _timestamp)) - END - ) + SUM(COALESCE(EXTRACT(EPOCH FROM (upper(part) - lower(part))), 0)) FROM ( SELECT - start_time, - duration, - (start_time + duration * interval '1 second') AS end_time - FROM session_history + unnest(range_agg(tstzrange(start_time, end_time)) * multirange(tstzrange(_start, _end))) AS part + FROM voice_sessions_combined WHERE (_guildid IS NULL OR guildid=_guildid) AND userid=_userid - AND (start_time + duration * interval '1 second') >= _timestamp - UNION - SELECT - start_time, - EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, - NOW() AS end_time - FROM current_sessions - WHERE - (_guildid IS NULL OR guildid=_guildid) - AND userid=_userid - ) AS sessions + AND start_time < _end + AND end_time > _start + ) AS disjoint_parts ); END; $$ LANGUAGE PLPGSQL; - -CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT) - RETURNS SETOF members +CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + RETURNS INTEGER AS $$ BEGIN - RETURN QUERY - WITH - current_sesh AS ( - DELETE FROM current_sessions - WHERE guildid=_guildid AND userid=_userid - RETURNING - *, - EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration, - stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration, - video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration, - live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration - ), bonus_userid AS ( - SELECT COUNT(boostedTimestamp), - CASE WHEN EXISTS ( - SELECT 1 FROM Topgg - WHERE Topgg.userid=_userid AND EXTRACT(EPOCH FROM (NOW() - boostedTimestamp)) < 12.5*60*60 - ) THEN - (array_agg( - CASE WHEN boostedTimestamp <= current_sesh.start_time THEN - 1.25 - ELSE - (((current_sesh.total_duration - EXTRACT(EPOCH FROM (boostedTimestamp - current_sesh.start_time)))/current_sesh.total_duration)*0.25)+1 - END))[1] - ELSE - 1 - END - AS bonus - FROM Topgg, current_sesh - WHERE Topgg.userid=_userid AND EXTRACT(EPOCH FROM (NOW() - boostedTimestamp)) < 12.5*60*60 - ORDER BY (array_agg(boostedTimestamp))[1] DESC LIMIT 1 - ), saved_sesh AS ( - INSERT INTO session_history ( - guildid, userid, channelid, rating, tag, channel_type, start_time, - duration, stream_duration, video_duration, live_duration, - coins_earned - ) SELECT - guildid, userid, channelid, rating, tag, channel_type, start_time, - total_duration, total_stream_duration, total_video_duration, total_live_duration, - LEAST(((total_duration * hourly_coins::bigint + live_duration * hourly_live_coins::bigint) * bonus_userid.bonus )/ 3600, 2147483647) - FROM current_sesh, bonus_userid - RETURNING * - ) - UPDATE members - SET - tracked_time=(tracked_time + saved_sesh.duration), - coins=LEAST(coins::bigint + saved_sesh.coins_earned::bigint, 2147483647) - FROM saved_sesh - WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid - RETURNING members.*; + RETURN (SELECT study_time_between(_guildid, _userid, _timestamp, NOW())); END; $$ LANGUAGE PLPGSQL; - -CREATE VIEW current_sessions_totals AS - SELECT - *, - EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration, - stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration, - video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration, - live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration - FROM current_sessions; - - -CREATE VIEW members_totals AS - SELECT - *, - sesh.start_time AS session_start, - tracked_time + COALESCE(sesh.total_duration, 0) AS total_tracked_time, - coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 3600, 0) AS total_coins - FROM members - LEFT JOIN current_sessions_totals sesh USING (guildid, userid); - - -CREATE VIEW member_ranks AS - SELECT - *, - row_number() OVER (PARTITION BY guildid ORDER BY total_tracked_time DESC, userid ASC) AS time_rank, - row_number() OVER (PARTITION BY guildid ORDER BY total_coins DESC, userid ASC) AS coin_rank - FROM members_totals; -- }}} --- Study Badge Data {{{ -CREATE VIEW current_study_badges AS - SELECT - *, - (SELECT r.badgeid - FROM study_badges r - WHERE r.guildid = members_totals.guildid AND members_totals.total_tracked_time > r.required_time - ORDER BY r.required_time DESC - LIMIT 1) AS current_study_badgeid - FROM members_totals; +-- Activity Rank Data {{{ +CREATE TABLE xp_ranks( + rankid SERIAL PRIMARY KEY, + roleid BIGINT NOT NULL, + guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE, + required INTEGER NOT NULL, + reward INTEGER NOT NULL, + message TEXT +); +CREATE UNIQUE INDEX xp_ranks_roleid ON xp_ranks (roleid); +CREATE INDEX xp_ranks_guild_required ON xp_ranks (guildid, required); + +CREATE TABLE voice_ranks( + rankid SERIAL PRIMARY KEY, + roleid BIGINT NOT NULL, + guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE, + required INTEGER NOT NULL, + reward INTEGER NOT NULL, + message TEXT +); +CREATE UNIQUE INDEX voice_ranks_roleid ON voice_ranks (roleid); +CREATE INDEX voice_ranks_guild_required ON voice_ranks (guildid, required); + +CREATE TABLE msg_ranks( + rankid SERIAL PRIMARY KEY, + roleid BIGINT NOT NULL, + guildid BIGINT NOT NULL REFERENCES guild_config ON DELETE CASCADE, + required INTEGER NOT NULL, + reward INTEGER NOT NULL, + message TEXT +); +CREATE UNIQUE INDEX msg_ranks_roleid ON msg_ranks (roleid); +CREATE INDEX msg_ranks_guild_required ON msg_ranks (guildid, required); + +CREATE TABLE member_ranks( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + current_xp_rankid INTEGER REFERENCES xp_ranks ON DELETE SET NULL, + current_voice_rankid INTEGER REFERENCES voice_ranks ON DELETE SET NULL, + current_msg_rankid INTEGER REFERENCES msg_ranks ON DELETE SET NULL, + last_roleid BIGINT, + PRIMARY KEY (guildid, userid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); + +CREATE TABLE season_stats( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + voice_stats INTEGER NOT NULL DEFAULT 0, + xp_stats INTEGER NOT NULL DEFAULT 0, + message_stats INTEGER NOT NULL DEFAULT 0, + season_start TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (guildid, userid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); -CREATE VIEW new_study_badges AS - SELECT - current_study_badges.* - FROM current_study_badges - WHERE - last_study_badgeid IS DISTINCT FROM current_study_badgeid - ORDER BY guildid; -- }}} -- Rented Room data {{{ -CREATE TABLE rented( +CREATE TABLE rented_rooms( channelid BIGINT PRIMARY KEY, guildid BIGINT NOT NULL, ownerid BIGINT NOT NULL, - expires_at TIMESTAMP DEFAULT ((now() at time zone 'utc') + INTERVAL '1 day'), - created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), + created_at TIMESTAMPTZ DEFAULT now(), + deleted_at TIMESTAMPTZ, + coin_balance INTEGER NOT NULL DEFAULT 0, + name TEXT, + last_tick TIMESTAMPTZ, + contribution INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (guildid, ownerid) REFERENCES members (guildid, userid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX rented_owners ON rented (guildid, ownerid); +CREATE INDEX rented_owners ON rented_rooms(guildid, ownerid); CREATE TABLE rented_members( - channelid BIGINT NOT NULL REFERENCES rented(channelid) ON DELETE CASCADE, + channelid BIGINT NOT NULL REFERENCES rented_rooms(channelid) ON DELETE CASCADE, userid BIGINT NOT NULL ); CREATE INDEX rented_members_channels ON rented_members (channelid); CREATE INDEX rented_members_users ON rented_members (userid); -- }}} --- Accountability Rooms {{{ -CREATE TABLE accountability_slots( - slotid SERIAL PRIMARY KEY, - guildid BIGINT NOT NULL REFERENCES guild_config(guildid), - channelid BIGINT, - start_at TIMESTAMPTZ (0) NOT NULL, - messageid BIGINT, - closed_at TIMESTAMPTZ +-- Scheduled Sessions {{{ +CREATE TABLE schedule_slots( + slotid INTEGER PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT now() ); -CREATE UNIQUE INDEX slot_channels ON accountability_slots(channelid); -CREATE UNIQUE INDEX slot_guilds ON accountability_slots(guildid, start_at); -CREATE INDEX slot_times ON accountability_slots(start_at); -CREATE TABLE accountability_members( - slotid INTEGER NOT NULL REFERENCES accountability_slots(slotid) ON DELETE CASCADE, - userid BIGINT NOT NULL, - paid INTEGER NOT NULL, - duration INTEGER DEFAULT 0, - last_joined_at TIMESTAMPTZ, - PRIMARY KEY (slotid, userid) +CREATE TABLE schedule_guild_config( + guildid BIGINT PRIMARY KEY REFERENCES guild_config ON DELETE CASCADE, + schedule_cost INTEGER, + reward INTEGER, + bonus_reward INTEGER, + min_attendance INTEGER, + lobby_channel BIGINT, + room_channel BIGINT, + blacklist_after INTEGER, + blacklist_role BIGINT, + created_at TIMESTAMPTZ DEFAULT now() ); -CREATE INDEX slot_members ON accountability_members(userid); -CREATE INDEX slot_members_slotid ON accountability_members(slotid); -CREATE VIEW accountability_member_info AS - SELECT - * - FROM accountability_members - JOIN accountability_slots USING (slotid); - -CREATE VIEW accountability_open_slots AS - SELECT - * - FROM accountability_slots - WHERE closed_at IS NULL - ORDER BY start_at ASC; --- }}} - --- Reaction Roles {{{ -CREATE TABLE reaction_role_messages( - messageid BIGINT PRIMARY KEY, - guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, +CREATE TABLE schedule_channels( + guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE, channelid BIGINT NOT NULL, - enabled BOOLEAN DEFAULT TRUE, - required_role BIGINT, - removable BOOLEAN, - maximum INTEGER, - refunds BOOLEAN, - event_log BOOLEAN, - default_price INTEGER + PRIMARY KEY (guildid, channelid) ); -CREATE INDEX reaction_role_guilds ON reaction_role_messages (guildid); -CREATE TABLE reaction_role_reactions( - reactionid SERIAL PRIMARY KEY, - messageid BIGINT NOT NULL REFERENCES reaction_role_messages (messageid) ON DELETE CASCADE, - roleid BIGINT NOT NULL, - emoji_name TEXT, - emoji_id BIGINT, - emoji_animated BOOLEAN, - price INTEGER, - timeout INTEGER +CREATE TABLE schedule_sessions( + guildid BIGINT NOT NULL REFERENCES schedule_guild_config ON DELETE CASCADE, + slotid INTEGER NOT NULL REFERENCES schedule_slots ON DELETE CASCADE, + opened_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + messageid BIGINT, + created_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, slotid) ); -CREATE INDEX reaction_role_reaction_messages ON reaction_role_reactions (messageid); -CREATE TABLE reaction_role_expiring( +CREATE TABLE schedule_session_members( guildid BIGINT NOT NULL, userid BIGINT NOT NULL, - roleid BIGINT NOT NULL, - expiry TIMESTAMPTZ NOT NULL, - reactionid INTEGER REFERENCES reaction_role_reactions (reactionid) ON DELETE SET NULL + slotid INTEGER NOT NULL, + booked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + attended BOOLEAN NOT NULL DEFAULT False, + clock INTEGER NOT NULL DEFAULT 0, + book_transactionid INTEGER REFERENCES coin_transactions, + reward_transactionid INTEGER REFERENCES coin_transactions, + PRIMARY KEY (guildid, userid, slotid), + FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE, + FOREIGN KEY (guildid, slotid) REFERENCES schedule_sessions (guildid, slotid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid); +CREATE INDEX schedule_session_members_users ON schedule_session_members(userid, slotid); + +-- }}} + +-- RoleMenus {{{ +CREATE TYPE RoleMenuType AS ENUM( + 'REACTION', + 'BUTTON', + 'DROPDOWN' +); + + +CREATE TABLE role_menus( + menuid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE, + channelid BIGINT, + messageid BIGINT, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT True, + required_roleid BIGINT, + sticky BOOLEAN, + refunds BOOLEAN, + obtainable INTEGER, + menutype RoleMenuType NOT NULL, + templateid INTEGER, + rawmessage TEXT, + default_price INTEGER, + event_log BOOLEAN +); +CREATE INDEX role_menu_guildid ON role_menus (guildid); + + + +CREATE TABLE role_menu_roles( + menuroleid SERIAL PRIMARY KEY, + menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE, + roleid BIGINT NOT NULL, + label TEXT NOT NULL, + emoji TEXT, + description TEXT, + price INTEGER, + duration INTEGER, + rawreply TEXT +); +CREATE INDEX role_menu_roles_menuid ON role_menu_roles (menuid); +CREATE INDEX role_menu_roles_roleid ON role_menu_roles (roleid); + + +CREATE TABLE role_menu_history( + equipid SERIAL PRIMARY KEY, + menuid INTEGER NOT NULL REFERENCES role_menus (menuid) ON DELETE CASCADE, + roleid BIGINT NOT NULL, + userid BIGINT NOT NULL, + obtained_at TIMESTAMPTZ NOT NULL, + transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL, + expires_at TIMESTAMPTZ, + removed_at TIMESTAMPTZ +); +CREATE INDEX role_menu_history_menuid ON role_menu_history (menuid); +CREATE INDEX role_menu_history_roleid ON role_menu_history (roleid); -- }}} -- Member Role Data {{{ @@ -845,12 +1136,40 @@ CREATE INDEX member_profile_tags_members ON member_profile_tags (guildid, userid -- }}} -- Member goals {{{ +CREATE TABLE user_weekly_goals( + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, + task_goal INTEGER, + study_goal INTEGER, + review_goal INTEGER, + message_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (userid, weekid), + FOREIGN KEY (userid) REFERENCES user_config (userid) ON DELETE CASCADE +); +CREATE INDEX user_weekly_goals_users ON user_weekly_goals (userid); + +CREATE TABLE user_monthly_goals( + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, + task_goal INTEGER, + study_goal INTEGER, + review_goal INTEGER, + message_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (userid, monthid), + FOREIGN KEY (userid) REFERENCES user_config (userid) ON DELETE CASCADE +); +CREATE INDEX user_monthly_goals_users ON user_monthly_goals (userid); + CREATE TABLE member_weekly_goals( guildid BIGINT NOT NULL, userid BIGINT NOT NULL, weekid INTEGER NOT NULL, -- Epoch time of the start of the UTC week study_goal INTEGER, task_goal INTEGER, + review_goal INTEGER, + message_goal INTEGER, _timestamp TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (guildid, userid, weekid), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE @@ -875,6 +1194,8 @@ CREATE TABLE member_monthly_goals( monthid INTEGER NOT NULL, -- Epoch time of the start of the UTC month study_goal INTEGER, task_goal INTEGER, + review_goal INTEGER, + message_goal INTEGER, _timestamp TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (guildid, userid, monthid), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE @@ -899,13 +1220,18 @@ CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_ta create TABLE timers( channelid BIGINT PRIMARY KEY, guildid BIGINT NOT NULL REFERENCES guild_config (guildid), - text_channelid BIGINT, + notification_channelid BIGINT, focus_length INTEGER NOT NULL, break_length INTEGER NOT NULL, - last_started TIMESTAMPTZ NOT NULL, + last_started TIMESTAMPTZ, inactivity_threshold INTEGER, channel_name TEXT, - pretty_name TEXT + pretty_name TEXT, + owenrid BIGINT REFERENCES user_config, + manager_roleid BIGINT, + last_messageid BIGINT, + voice_alerts BOOLEAN, + auto_restart BOOLEAN ); CREATE INDEX timers_guilds ON timers (guildid); -- }}} @@ -1038,4 +1364,69 @@ CREATE TABLE premium_guild_contributions( -- }}} +-- Analytics Data {{{ +CREATE SCHEMA "analytics"; + +CREATE TABLE analytics.snapshots( + snapshotid SERIAL PRIMARY KEY, + appname TEXT NOT NULL REFERENCES bot_config (appname), + guild_count INTEGER NOT NULL, + member_count INTEGER NOT NULL, + user_count INTEGER NOT NULL, + in_voice INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') +); + + +CREATE TABLE analytics.events( + eventid SERIAL PRIMARY KEY, + appname TEXT NOT NULL REFERENCES bot_config (appname), + ctxid BIGINT, + guildid BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc') +); + +CREATE TYPE analytics.CommandStatus AS ENUM( + 'COMPLETED', + 'CANCELLED', + 'FAILED' +); + +CREATE TABLE analytics.commands( + cmdname TEXT NOT NULL, + cogname TEXT, + userid BIGINT NOT NULL, + status analytics.CommandStatus NOT NULL, + error TEXT, + execution_time REAL NOT NULL +) INHERITS (analytics.events); + + +CREATE TYPE analytics.GuildAction AS ENUM( + 'JOINED', + 'LEFT' +); + +CREATE TABLE analytics.guilds( + guildid BIGINT NOT NULL, + action analytics.GuildAction NOT NULL +) INHERITS (analytics.events); + + +CREATE TYPE analytics.VoiceAction AS ENUM( + 'JOINED', + 'LEFT' +); + +CREATE TABLE analytics.voice_sessions( + userid BIGINT NOT NULL, + action analytics.VoiceAction NOT NULL +) INHERITS (analytics.events); + +CREATE TABLE analytics.gui_renders( + cardname TEXT NOT NULL, + duration INTEGER NOT NULL +) INHERITS (analytics.events); +-- }}} + -- vim: set fdm=marker: From aa29dd48be0b31693b14f59f1ef042540d6ff84a Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 10 Oct 2023 07:55:26 +0300 Subject: [PATCH 32/49] fix(stats): Empty achievement protection. --- src/modules/statistics/achievements.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/modules/statistics/achievements.py b/src/modules/statistics/achievements.py index 87d3a928..c9c23c40 100644 --- a/src/modules/statistics/achievements.py +++ b/src/modules/statistics/achievements.py @@ -161,7 +161,7 @@ class Workout(Achievement): record = await self.bot.core.data.workouts.select_one_where( guildid=self.guildid, userid=self.userid ).select(total='COUNT(*)') - return int(record['total']) + return int(record['total'] or 0) class VoiceHours(Achievement): @@ -187,7 +187,7 @@ class VoiceHours(Achievement): 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) + return int(hours or 0) class VoiceStreak(Achievement): @@ -295,7 +295,7 @@ class Voting(Achievement): record = await self.bot.core.data.topgg.select_one_where( userid=self.userid ).select(total='COUNT(*)') - return int(record['total']) + return int(record['total'] or 0) class VoiceDays(Achievement): @@ -324,7 +324,7 @@ class VoiceDays(Achievement): 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) + return int(days or 0) class TasksComplete(Achievement): @@ -354,7 +354,7 @@ class TasksComplete(Achievement): ).with_no_adapter() completed = records[0]['total'] if records else 0 - return int(completed) + return int(completed or 0) class ScheduledSessions(Achievement): @@ -383,7 +383,7 @@ class ScheduledSessions(Achievement): total='COUNT(*)' ).with_no_adapter() - return int(records[0]['total'] if records else 0) + return int((records[0]['total'] or 0) if records else 0) class MonthlyHours(Achievement): @@ -411,7 +411,9 @@ class MonthlyHours(Achievement): ).select( _first='MIN(start_time)' ).with_no_adapter() - first_session = records[0]['_first'] if records else lion.today - dt.timedelta(days=1) + 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 From 4e8eb366f375b22085a3c706877b0f35f7d6e473 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 10 Oct 2023 08:43:39 +0300 Subject: [PATCH 33/49] feat(economy): Implement starting_funds setting. --- src/core/lion.py | 9 ++++++--- src/modules/economy/cog.py | 16 +++++++++++++--- src/modules/economy/settings.py | 32 +++++++++++++++++++++++++++++++- src/modules/economy/settingui.py | 15 +++++++-------- src/utils/ui/config.py | 3 +++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/core/lion.py b/src/core/lion.py index 0531a84c..5e695d88 100644 --- a/src/core/lion.py +++ b/src/core/lion.py @@ -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) diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index 9e72e4ce..cb7a37e9 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -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: @@ -847,11 +848,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 +866,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 +877,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 +888,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) diff --git a/src/modules/economy/settings.py b/src/modules/economy/settings.py index 4d1fb815..076710d4 100644 --- a/src/modules/economy/settings.py +++ b/src/modules/economy/settings.py @@ -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) diff --git a/src/modules/economy/settingui.py b/src/modules/economy/settingui.py index 16254d1a..f357d6e5 100644 --- a/src/modules/economy/settingui.py +++ b/src/modules/economy/settingui.py @@ -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): diff --git a/src/utils/ui/config.py b/src/utils/ui/config.py index 59dd4a98..e1567e1f 100644 --- a/src/utils/ui/config.py +++ b/src/utils/ui/config.py @@ -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() From fe81945391a2ee6a15dffe0dd774cdfc8c4ba887 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 10 Oct 2023 16:05:57 +0300 Subject: [PATCH 34/49] feat(eventlog): Add eventlog setting. Also refactors the GeneralSettings to use the new style. --- src/core/lion_guild.py | 9 ++ src/modules/config/__init__.py | 6 +- src/modules/config/cog.py | 86 ++++++++++++++++- src/modules/config/general.py | 158 ++++++++++++------------------- src/modules/config/settings.py | 79 ++++++++++++++++ src/modules/config/settingsui.py | 0 src/modules/config/settingui.py | 107 +++++++++++++++++++++ 7 files changed, 339 insertions(+), 106 deletions(-) create mode 100644 src/modules/config/settings.py create mode 100644 src/modules/config/settingsui.py create mode 100644 src/modules/config/settingui.py diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index b5141ca9..218e38c3 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -93,3 +93,12 @@ class LionGuild(Timezoned): """ if self.data.name != guild.name: await self.data.update(name=guild.name) + + async def _event_log(self, ...): + ... + + def event_log(self, **kwargs): + asyncio.create_task(self._event_log(**kwargs), name='event-log') + + def error_log(self, ...): + ... diff --git a/src/modules/config/__init__.py b/src/modules/config/__init__.py index 616c68a6..f4a849d1 100644 --- a/src/modules/config/__init__.py +++ b/src/modules/config/__init__.py @@ -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)) diff --git a/src/modules/config/cog.py b/src/modules/config/cog.py index 56926d4a..02850d8c 100644 --- a/src/modules/config/cog.py +++ b/src/modules/config/cog.py @@ -3,22 +3,31 @@ 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", @@ -30,3 +39,72 @@ class DashCog(LionCog): ui = GuildDashboard(self.bot, ctx.guild, ctx.author.id, ctx.channel.id) await ui.run(ctx.interaction) await ui.wait() + + @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._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, + 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) + + # ----- 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._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, + 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) + # TODO diff --git a/src/modules/config/general.py b/src/modules/config/general.py index 4b57c844..d729fc07 100644 --- a/src/modules/config/general.py +++ b/src/modules/config/general.py @@ -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,68 +45,72 @@ 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 - ) - @appcmds.describe( - timezone=GeneralSettings.Timezone._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): - t = self.bot.translator.t + @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._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, + 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) + # 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) - updated = [] # Possibly empty list of setting instances which were updated, with new data stored - error_embed = None + updated = [] # Possibly empty list of setting instances which were updated, with new data stored + error_embed = None - if timezone is not None: - try: - timezone_setting = await self.settings.Timezone.from_string(ctx.guild.id, timezone) - updated.append(timezone_setting) - except UserInputError as err: - error_embed = discord.Embed( - colour=discord.Colour.brand_red(), + if timezone is not None: + try: + timezone_setting = await self.settings.Timezone.from_string(ctx.guild.id, timezone) + updated.append(timezone_setting) + except UserInputError as err: + error_embed = discord.Embed( + colour=discord.Colour.brand_red(), + title=t(_p( + 'cmd:configure_general|parse_failure:timezone', + "Could not set the timezone!" + )), + description=err.msg + ) + + if error_embed is not None: + # User requested configuration updated, but we couldn't parse input + await ctx.reply(embed=error_embed) + elif updated: + # Save requested configuration updates + results = [] # List of "success" update responses for each updated setting + for to_update in updated: + # TODO: Again need a better way of batch writing + # Especially since most of these are on one model... + await to_update.write() + results.append(to_update.update_message) + # Post aggregated success message + success_embed = discord.Embed( + colour=discord.Colour.brand_green(), title=t(_p( - 'cmd:configure_general|parse_failure:timezone', - "Could not set the timezone!" + 'cmd:configure_general|success', + "Settings Updated!" )), - description=err.msg - ) - - if error_embed is not None: - # User requested configuration updated, but we couldn't parse input - await ctx.reply(embed=error_embed) - elif updated: - # Save requested configuration updates - results = [] # List of "success" update responses for each updated setting - for to_update in updated: - # TODO: Again need a better way of batch writing - # Especially since most of these are on one model... - await to_update.write() - results.append(to_update.update_message) - # Post aggregated success message - success_embed = discord.Embed( - colour=discord.Colour.brand_green(), - title=t(_p( - 'cmd:configure_general|success', - "Settings Updated!" - )), description='\n'.join( f"{self.bot.config.emojis.tick} {line}" for line in results ) diff --git a/src/modules/config/settings.py b/src/modules/config/settings.py new file mode 100644 index 00000000..1e62bf31 --- /dev/null +++ b/src/modules/config/settings.py @@ -0,0 +1,79 @@ +from settings import ModelData +from settings.setting_types import TimezoneSetting, ChannelSetting +from settings.groups import SettingGroup + +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 = '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 EventLog(ModelData, ChannelSetting): + """ + Guild event log channel. + """ + setting_id = 'eventlog' + _event = 'guildset_eventlog' + + _display_name = _p('guildset:eventlog', "event_log") + _desc = _p( + 'guildset:eventlog|desc', + "Channel to which to log server events, such as voice sessions and equipped roles." + ) + # TODO: Reword + _long_desc = _p( + 'guildset:eventlog|long_desc', + "An audit log for my own systems, " + "I will send most significant actions and events that occur through my interface " + "to this channel. For example, this includes:\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." + ) + + # TODO: Updatestr diff --git a/src/modules/config/settingsui.py b/src/modules/config/settingsui.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/config/settingui.py b/src/modules/config/settingui.py new file mode 100644 index 00000000..d01b6478 --- /dev/null +++ b/src/modules/config/settingui.py @@ -0,0 +1,107 @@ +import asyncio + +import discord +from discord.ui.select import select, ChannelSelect + +from meta import LionBot + +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('GeneralSettingsCog').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] if selected.values else None + if issue := (await setting.check_value(value)): + raise UserInputError(issue) + + setting.value = 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 Dashboard Settings ({commands[configure general]})" + ) + _option_name = _p( + "dash:general|option|name", + "General Configuration Panel" + ) + configui = GeneralSettingsUI + setting_classes = configui.setting_classes From 1a6c32adeaee07cc6e29c40ad2f199bf76958507 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 10 Oct 2023 20:08:21 +0300 Subject: [PATCH 35/49] fix: Use https submodule urls. --- .gitmodules | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index e45415a2..8221f98f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "bot/gui"] path = src/gui - url = cgithub:StudyLions/StudyLion-Plugin-GUI.git + url = https://github.com/StudyLions/StudyLion-Plugin-GUI.git [submodule "skins"] path = skins - url = cgithub:StudyLions/StudyLion-Plugin-Skins.git + url = https://github.com/StudyLions/StudyLion-Plugin-Skins.git From 66e7c2f2e4ea334d8959c92552dbef6d1f0441c1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 11 Oct 2023 07:28:53 +0300 Subject: [PATCH 36/49] fix(config): Fix general settings UI. --- src/core/lion_guild.py | 10 +- src/modules/config/cog.py | 74 ++++++++------- src/modules/config/general.py | 150 +++++++++++++++--------------- src/modules/config/settings.py | 55 ++++++++--- src/modules/config/settingsui.py | 0 src/modules/config/settingui.py | 16 ++-- src/modules/pomodoro/options.py | 2 +- src/modules/pomodoro/ui/config.py | 12 +-- src/settings/ui.py | 11 ++- 9 files changed, 178 insertions(+), 152 deletions(-) delete mode 100644 src/modules/config/settingsui.py diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index 218e38c3..716afaa0 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -80,7 +80,7 @@ class LionGuild(Timezoned): return GuildMode.StudyGuild @property - def timezone(self) -> pytz.timezone: + def timezone(self) -> str: return self.config.timezone.value @property @@ -94,11 +94,3 @@ class LionGuild(Timezoned): if self.data.name != guild.name: await self.data.update(name=guild.name) - async def _event_log(self, ...): - ... - - def event_log(self, **kwargs): - asyncio.create_task(self._event_log(**kwargs), name='event-log') - - def error_log(self, ...): - ... diff --git a/src/modules/config/cog.py b/src/modules/config/cog.py index 02850d8c..307b1df0 100644 --- a/src/modules/config/cog.py +++ b/src/modules/config/cog.py @@ -1,3 +1,5 @@ +from typing import Optional + import discord from discord import app_commands as appcmds from discord.ext import commands as cmds @@ -22,7 +24,7 @@ class GuildConfigCog(LionCog): 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) + self.bot.core.guild_config.register_model_setting(GeneralSettings.EventLog) configcog = self.bot.get_cog('ConfigCog') if configcog is None: @@ -36,43 +38,13 @@ class GuildConfigCog(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() - @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._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, - 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) - # ----- Configuration ----- @LionCog.placeholder_group @cmds.hybrid_group("configure", with_app_command=False) @@ -90,7 +62,7 @@ class GuildConfigCog(LionCog): ) @appcmds.describe( timezone=GeneralSettings.Timezone._desc, - event_log=GeneralSettings.EventLog._display_name, + event_log=GeneralSettings.EventLog._desc, ) @appcmds.guild_only() @appcmds.default_permissions(manage_guild=True) @@ -107,4 +79,34 @@ class GuildConfigCog(LionCog): if not ctx.interaction: return await ctx.interaction.response.defer(thinking=True) - # TODO + + 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() + diff --git a/src/modules/config/general.py b/src/modules/config/general.py index d729fc07..c3fd1163 100644 --- a/src/modules/config/general.py +++ b/src/modules/config/general.py @@ -48,88 +48,88 @@ class GeneralSettingsCog(LionCog): # 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._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, - event_log: Optional[discord.TextChannel] = None, - ): - t = self.bot.translator.t + @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._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, + 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) + # 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) - updated = [] # Possibly empty list of setting instances which were updated, with new data stored - error_embed = None + updated = [] # Possibly empty list of setting instances which were updated, with new data stored + error_embed = None - if timezone is not None: - try: - timezone_setting = await self.settings.Timezone.from_string(ctx.guild.id, timezone) - updated.append(timezone_setting) - except UserInputError as err: - error_embed = discord.Embed( - colour=discord.Colour.brand_red(), - title=t(_p( - 'cmd:configure_general|parse_failure:timezone', - "Could not set the timezone!" - )), - description=err.msg - ) - - if error_embed is not None: - # User requested configuration updated, but we couldn't parse input - await ctx.reply(embed=error_embed) - elif updated: - # Save requested configuration updates - results = [] # List of "success" update responses for each updated setting - for to_update in updated: - # TODO: Again need a better way of batch writing - # Especially since most of these are on one model... - await to_update.write() - results.append(to_update.update_message) - # Post aggregated success message - success_embed = discord.Embed( - colour=discord.Colour.brand_green(), + if timezone is not None: + try: + timezone_setting = await self.settings.Timezone.from_string(ctx.guild.id, timezone) + updated.append(timezone_setting) + except UserInputError as err: + error_embed = discord.Embed( + colour=discord.Colour.brand_red(), title=t(_p( - 'cmd:configure_general|success', - "Settings Updated!" + 'cmd:configure_general|parse_failure:timezone', + "Could not set the timezone!" )), - description='\n'.join( - f"{self.bot.config.emojis.tick} {line}" for line in results + description=err.msg ) - ) - 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(), + + if error_embed is not None: + # User requested configuration updated, but we couldn't parse input + await ctx.reply(embed=error_embed) + elif updated: + # Save requested configuration updates + results = [] # List of "success" update responses for each updated setting + for to_update in updated: + # TODO: Again need a better way of batch writing + # Especially since most of these are on one model... + await to_update.write() + results.append(to_update.update_message) + # Post aggregated success message + success_embed = discord.Embed( + colour=discord.Colour.brand_green(), title=t(_p( - 'cmd:configure_general|panel|title', - "General Configuration Panel" - )) + 'cmd:configure_general|success', + "Settings Updated!" + )), + description='\n'.join( + f"{self.bot.config.emojis.tick} {line}" for line in results ) - 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) diff --git a/src/modules/config/settings.py b/src/modules/config/settings.py index 1e62bf31..87c5f0d4 100644 --- a/src/modules/config/settings.py +++ b/src/modules/config/settings.py @@ -1,7 +1,12 @@ +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 @@ -20,7 +25,8 @@ class GeneralSettings(SettingGroup): and the timezone used to display guild-wide statistics. """ setting_id = 'timezone' - _event = 'guild_setting_update_timezone' + _event = 'guildset_timezone' + _set_cmd = 'configure general' _display_name = _p('guildset:timezone', "timezone") _desc = _p( @@ -46,29 +52,24 @@ class GeneralSettings(SettingGroup): "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 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', - "Channel to which to log server events, such as voice sessions and equipped roles." + "My audit log channel where I send server actions and events (e.g. rankgs and expiring roles)." ) - # TODO: Reword _long_desc = _p( 'guildset:eventlog|long_desc', - "An audit log for my own systems, " - "I will send most significant actions and events that occur through my interface " - "to this channel. For example, this includes:\n" + "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" @@ -76,4 +77,34 @@ class GeneralSettings(SettingGroup): "I must have the 'Manage Webhooks' permission in this channel." ) - # TODO: Updatestr + _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 diff --git a/src/modules/config/settingsui.py b/src/modules/config/settingsui.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/modules/config/settingui.py b/src/modules/config/settingui.py index d01b6478..7c5ea28a 100644 --- a/src/modules/config/settingui.py +++ b/src/modules/config/settingui.py @@ -4,6 +4,7 @@ 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 @@ -18,11 +19,11 @@ _p = babel._p class GeneralSettingUI(ConfigUI): setting_classes = ( GeneralSettings.Timezone, - GeneralSettings.Eventlog, + GeneralSettings.EventLog, ) def __init__(self, bot: LionBot, guildid: int, channelid: int, **kwargs): - self.settings = bot.get_cog('GeneralSettingsCog').settings + self.settings = bot.get_cog('GuildConfigCog').settings super().__init__(bot, guildid, channelid, **kwargs) # ----- UI Components ----- @@ -39,13 +40,10 @@ class GeneralSettingUI(ConfigUI): """ await selection.response.defer(thinking=True, ephemeral=True) - setting = self.get_instance(GeneralSettings.Eventlog) + setting = self.get_instance(GeneralSettings.EventLog) - value = selected.values[0] if selected.values else None - if issue := (await setting.check_value(value)): - raise UserInputError(issue) - - setting.value = value + 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() @@ -103,5 +101,5 @@ class GeneralDashboard(DashboardSection): "dash:general|option|name", "General Configuration Panel" ) - configui = GeneralSettingsUI + configui = GeneralSettingUI setting_classes = configui.setting_classes diff --git a/src/modules/pomodoro/options.py b/src/modules/pomodoro/options.py index 76f9e10d..a88211f6 100644 --- a/src/modules/pomodoro/options.py +++ b/src/modules/pomodoro/options.py @@ -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 diff --git a/src/modules/pomodoro/ui/config.py b/src/modules/pomodoro/ui/config.py index b4badbb6..2fd92b89 100644 --- a/src/modules/pomodoro/ui/config.py +++ b/src/modules/pomodoro/ui/config.py @@ -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( diff --git a/src/settings/ui.py b/src/settings/ui.py index e53a874c..5fff6e32 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -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 From 4457e60120d7a3478f00ee5b1b4e23c6a7cf8d58 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 12 Oct 2023 09:32:17 +0300 Subject: [PATCH 37/49] feat(core): Channel hook manager. --- src/core/cog.py | 11 ++++- src/core/hooks.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/core/hooks.py diff --git a/src/core/cog.py b/src/core/cog.py index 3f3cc6c1..d78f6dde 100644 --- a/src/core/cog.py +++ b/src/core/cog.py @@ -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"" 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') diff --git a/src/core/hooks.py b/src/core/hooks.py new file mode 100644 index 00000000..92b81e8c --- /dev/null +++ b/src/core/hooks.py @@ -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) From 4827defd5d893a07ef307b094698c254a5277ed1 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Thu, 12 Oct 2023 20:30:26 +1300 Subject: [PATCH 38/49] Fix ticket log timestamps showing incorrect time --- src/modules/moderation/ticket.py | 3 ++- src/modules/video_channels/ticket.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/moderation/ticket.py b/src/modules/moderation/ticket.py index 4c8cba1a..b4559d46 100644 --- a/src/modules/moderation/ticket.py +++ b/src/modules/moderation/ticket.py @@ -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( diff --git a/src/modules/video_channels/ticket.py b/src/modules/video_channels/ticket.py index b19a7fc5..0203c482 100644 --- a/src/modules/video_channels/ticket.py +++ b/src/modules/video_channels/ticket.py @@ -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) @@ -94,6 +96,7 @@ class VideoTicket(Ticket): **kwargs ) + async def _revert(self, reason=None): target = self.target blacklist = self.lguild.config.get(VideoSettings.VideoBlacklist.setting_id).value From 34e7be64b747695131ea091e6b05cdcbe328ff5b Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Thu, 12 Oct 2023 20:32:57 +1300 Subject: [PATCH 39/49] Remove extra whitespace --- src/modules/video_channels/ticket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/video_channels/ticket.py b/src/modules/video_channels/ticket.py index 0203c482..3cd4deba 100644 --- a/src/modules/video_channels/ticket.py +++ b/src/modules/video_channels/ticket.py @@ -96,7 +96,6 @@ class VideoTicket(Ticket): **kwargs ) - async def _revert(self, reason=None): target = self.target blacklist = self.lguild.config.get(VideoSettings.VideoBlacklist.setting_id).value From 7b6290b73ed120bcc15788467b619c59c77607d6 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 14 Oct 2023 01:07:46 +0300 Subject: [PATCH 40/49] feat(core): Implement event log interface. --- src/core/__init__.py | 6 +- src/core/lion_guild.py | 235 +++++++++++++++++++++++++++++++- src/modules/config/dashboard.py | 3 +- src/modules/config/settingui.py | 2 +- 4 files changed, 237 insertions(+), 9 deletions(-) diff --git a/src/core/__init__.py b/src/core/__init__.py index 64672d49..4e70302b 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -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)) diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index 716afaa0..f68618b8 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -1,20 +1,75 @@ 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, + ), + '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 +104,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 +132,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: @@ -93,4 +175,149 @@ 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 ", + 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 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]]={}, + **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. + 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 + base = embed if embed is not None else discord.Embed(colour=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, + ) + + # 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) diff --git a/src/modules/config/dashboard.py b/src/modules/config/dashboard.py index bc19d330..c0f8f8e6 100644 --- a/src/modules/config/dashboard.py +++ b/src/modules/config/dashboard.py @@ -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,), diff --git a/src/modules/config/settingui.py b/src/modules/config/settingui.py index 7c5ea28a..3359fa9d 100644 --- a/src/modules/config/settingui.py +++ b/src/modules/config/settingui.py @@ -95,7 +95,7 @@ class GeneralSettingUI(ConfigUI): class GeneralDashboard(DashboardSection): section_name = _p( "dash:general|title", - "General Dashboard Settings ({commands[configure general]})" + "General Configuration ({commands[configure general]})" ) _option_name = _p( "dash:general|option|name", From 1586354b39c648d8f501698c60c7fa9f88cac65c Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 14 Oct 2023 01:08:12 +0300 Subject: [PATCH 41/49] feat(voice): Event logging for voice sessions. --- src/tracking/voice/cog.py | 22 +++++- src/tracking/voice/data.py | 6 ++ src/tracking/voice/session.py | 134 ++++++++++++++++++++++++++-------- 3 files changed, 130 insertions(+), 32 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 93d706fe..2b1645d8 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -505,10 +505,27 @@ class VoiceTrackerCog(LionCog): logger.debug( f"Scheduling voice session for member `{member.name}' " f"in guild '{member.guild.name}' " - f"in channel '{achannel}' . " + 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) + + 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 @@ -584,7 +601,8 @@ class VoiceTrackerCog(LionCog): start_time = now delay = 20 - expiry = start_time + dt.timedelta(seconds=cap) + remaining = cap - studied_today + expiry = start_time + dt.timedelta(seconds=remaining) if expiry > tomorrow: expiry = tomorrow + dt.timedelta(seconds=cap) diff --git a/src/tracking/voice/data.py b/src/tracking/voice/data.py index 86c5e500..3b835231 100644 --- a/src/tracking/voice/data.py +++ b/src/tracking/voice/data.py @@ -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): @@ -113,6 +114,11 @@ class VoiceTrackerData(Registry): 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: diff --git a/src/tracking/voice/session.py b/src/tracking/voice/session.py index 37de1cdc..37b9e10b 100644 --- a/src/tracking/voice/session.py +++ b/src/tracking/voice/session.py @@ -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: @@ -243,20 +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 in guild " - f"and channel ." - ) - # TODO: Would be better not to close the session and wipe the state - # Instead start a new PENDING session. - 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. @@ -282,26 +270,95 @@ 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 in guild " + f"and channel ." + ) + 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: - 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) - - # 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) - )) + 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() @@ -319,3 +376,20 @@ class VoiceSession: # 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() + 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) + + # 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) + )) From 2ae4379cd2c78ae1bd09a1c4a1de54bd4bd7fd4e Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 14 Oct 2023 16:09:35 +0300 Subject: [PATCH 42/49] feat(ranks): Implement event logging. --- src/core/lion_guild.py | 21 ++++- src/modules/ranks/cog.py | 187 ++++++++++++++++++++++++++++++--------- 2 files changed, 165 insertions(+), 43 deletions(-) diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index f68618b8..1912c24f 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -254,6 +254,7 @@ class LionGuild(Timezoned): *, 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. @@ -273,6 +274,9 @@ class LionGuild(Timezoned): 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 @@ -282,7 +286,12 @@ class LionGuild(Timezoned): t = self.bot.translator.t # Build embed - base = embed if embed is not None else discord.Embed(colour=discord.Colour.dark_orange()) + 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: @@ -317,6 +326,16 @@ class LionGuild(Timezoned): 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) diff --git a/src/modules/ranks/cog.py b/src/modules/ranks/cog.py index 4940d249..7d1da652 100644 --- a/src/modules/ranks/cog.py +++ b/src/modules/ranks/cog.py @@ -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 in : {roleids}" ) new_last_roleid = None - except discord.HTTPException: + except discord.HTTPException as e: logger.warning( f"Unexpected error removing old rank roles from in : {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 to in ." - ) - new_last_roleid = to_add.id - except discord.HTTPException: - logger.warning( - f"Unexpected error giving in their rank role ", - 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 to in ." + ) + last_roleid=to_add.id + except discord.HTTPException as e: + logger.warning( + f"Unexpected error giving in " + f"their rank role ", + 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 in : {roleids}" ) last_roleid = None - except discord.HTTPException: + except discord.HTTPException as e: logger.warning( f"Unexpected error removing old rank roles from in : {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 to in ." - ) - last_roleid=to_add.id - except discord.HTTPException: - logger.warning( - f"Unexpected error giving in their rank role ", - 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 to in ." + ) + last_roleid=to_add.id + except discord.HTTPException as e: + logger.warning( + f"Unexpected error giving in " + f"their rank role ", + 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): From 4148dc1ae838236eacca7bbd9a879bbda8eeb40a Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 14 Oct 2023 23:13:36 +0300 Subject: [PATCH 43/49] feat(rooms): Implement event logging. --- src/modules/rooms/cog.py | 61 ++++++++++++++++++++---- src/modules/rooms/room.py | 97 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 9 deletions(-) diff --git a/src/modules/rooms/cog.py b/src/modules/rooms/cog.py index 4b2a6d70..3490848a 100644 --- a/src/modules/rooms/cog.py +++ b/src/modules/rooms/cog.py @@ -168,6 +168,20 @@ 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 @@ -228,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 @@ -247,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() @@ -289,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 @@ -490,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: @@ -545,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( @@ -558,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"), diff --git a/src/modules/rooms/room.py b/src/modules/rooms/room.py index 9e34b874..22c841a6 100644 --- a/src/modules/rooms/room.py +++ b/src/modules/rooms/room.py @@ -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") From dde88c464b9f62909f296222ab371d858223c1ad Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 13:26:10 +0300 Subject: [PATCH 44/49] feat(menus): Implement event logging. --- src/core/lion_guild.py | 5 +++ src/modules/rolemenus/cog.py | 59 ++++++++++++++++++++++++++++++- src/modules/rolemenus/rolemenu.py | 38 ++++++++++++++++++-- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index 1912c24f..47172437 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -57,6 +57,11 @@ event_fields = { "{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}>", diff --git a/src/modules/rolemenus/cog.py b/src/modules/rolemenus/cog.py index 051cfb32..fd9defd8 100644 --- a/src/modules/rolemenus/cog.py +++ b/src/modules/rolemenus/cog.py @@ -15,10 +15,11 @@ 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.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 @@ -315,6 +316,11 @@ 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) @@ -322,6 +328,10 @@ class RoleMenuCog(LionCog): 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: @@ -329,9 +339,56 @@ class RoleMenuCog(LionCog): 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.") diff --git a/src/modules/rolemenus/rolemenu.py b/src/modules/rolemenus/rolemenu.py index bc4437af..1f3a7bb3 100644 --- a/src/modules/rolemenus/rolemenu.py +++ b/src/modules/rolemenus/rolemenu.py @@ -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( From 71448e8fa4553dcf62602bc17456da0dd5cdc03e Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 14:22:04 +0300 Subject: [PATCH 45/49] feat(economy): Implement event logging. --- src/modules/economy/cog.py | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/modules/economy/cog.py b/src/modules/economy/cog.py index cb7a37e9..db383d4f 100644 --- a/src/modules/economy/cog.py +++ b/src/modules/economy/cog.py @@ -299,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(): @@ -360,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', @@ -782,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 From ed683810cb782a58fc565fead12f5612f6bee19f Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 14:49:40 +0300 Subject: [PATCH 46/49] feat(admin): Implement event logging. --- src/modules/member_admin/cog.py | 86 ++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/src/modules/member_admin/cog.py b/src/modules/member_admin/cog.py index a707a3ea..250db0b0 100644 --- a/src/modules/member_admin/cog.py +++ b/src/modules/member_admin/cog.py @@ -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 in ." ) - # 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): From 3334f4996d480cd7da7928af2a2cf083f9e7308b Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 14:57:28 +0300 Subject: [PATCH 47/49] feat(shops): Implement event logging. --- src/core/lion_guild.py | 5 +++++ src/modules/shop/shops/colours.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/core/lion_guild.py b/src/core/lion_guild.py index 47172437..49fb98ab 100644 --- a/src/core/lion_guild.py +++ b/src/core/lion_guild.py @@ -57,6 +57,11 @@ event_fields = { "{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), diff --git a/src/modules/shop/shops/colours.py b/src/modules/shop/shops/colours.py index d43f531d..a1e5a7fb 100644 --- a/src/modules/shop/shops/colours.py +++ b/src/modules/shop/shops/colours.py @@ -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() From 2899a2e463d8654ba3a239e50cd944f9ecb74ec7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 15:58:36 +0300 Subject: [PATCH 48/49] chore: Update string templates. --- locales/templates/Pomodoro.pot | 56 ++--- locales/templates/babel.pot | 2 +- locales/templates/config.pot | 42 +--- locales/templates/core_config.pot | 2 +- locales/templates/economy.pot | 231 ++++++++++++------- locales/templates/exec.pot | 2 +- locales/templates/goals-gui.pot | 2 +- locales/templates/leaderboard-gui.pot | 2 +- locales/templates/lion-core.pot | 75 +++++- locales/templates/member_admin.pot | 75 +++++- locales/templates/meta.pot | 2 +- locales/templates/moderation.pot | 30 +-- locales/templates/monthly-gui.pot | 2 +- locales/templates/profile-gui.pot | 2 +- locales/templates/ranks.pot | 174 ++++++++++---- locales/templates/reminders.pot | 2 +- locales/templates/rolemenus.pot | 314 ++++++++++++++++---------- locales/templates/rooms.pot | 236 +++++++++++++------ locales/templates/schedule.pot | 2 +- locales/templates/settings_base.pot | 2 +- locales/templates/shop.pot | 177 ++++++++------- locales/templates/statistics.pot | 107 ++++++--- locales/templates/stats-gui.pot | 48 ++-- locales/templates/sysadmin.pot | 2 +- locales/templates/tasklist.pot | 2 +- locales/templates/test.pot | 34 +-- locales/templates/text-tracker.pot | 10 +- locales/templates/timer-gui.pot | 2 +- locales/templates/user_config.pot | 2 +- locales/templates/utils.pot | 38 ++-- locales/templates/video.pot | 2 +- locales/templates/voice-tracker.pot | 118 +++++++--- locales/templates/wards.pot | 2 +- locales/templates/weekly-gui.pot | 2 +- 34 files changed, 1162 insertions(+), 639 deletions(-) diff --git a/locales/templates/Pomodoro.pot b/locales/templates/Pomodoro.pot index 444e8e8c..0d280f65 100644 --- a/locales/templates/Pomodoro.pot +++ b/locales/templates/Pomodoro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -28,36 +28,36 @@ msgctxt "timer|stage:focus|name" msgid "FOCUS" msgstr "" -#: src/modules/pomodoro/timer.py:160 +#: src/modules/pomodoro/timer.py:164 #, possible-python-brace-format msgctxt "timer|webhook|name" msgid "{bot_name} Pomodoro" msgstr "" -#: src/modules/pomodoro/timer.py:164 +#: src/modules/pomodoro/timer.py:168 msgctxt "timer|webhook|audit_reason" msgid "Pomodoro Notifications" msgstr "" -#: src/modules/pomodoro/timer.py:175 +#: src/modules/pomodoro/timer.py:179 msgctxt "timer|webhook|error:insufficient_permissions" msgid "" "I require the `MANAGE_WEBHOOKS` permission to send pomodoro notifications " "here!" msgstr "" -#: src/modules/pomodoro/timer.py:234 +#: src/modules/pomodoro/timer.py:238 #, possible-python-brace-format msgctxt "timer|default_base_name" msgid "Timer {pattern}" msgstr "" -#: src/modules/pomodoro/timer.py:409 +#: src/modules/pomodoro/timer.py:413 msgctxt "timer|disconnect|audit_reason" msgid "Disconnecting inactive member from timer." msgstr "" -#: src/modules/pomodoro/timer.py:421 +#: src/modules/pomodoro/timer.py:425 #, possible-python-brace-format msgctxt "timer|kicked_message" msgid "" @@ -69,7 +69,7 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: src/modules/pomodoro/timer.py:434 +#: src/modules/pomodoro/timer.py:438 #, possible-python-brace-format msgctxt "timer|kick_failed" msgid "" @@ -77,20 +77,20 @@ msgid "" "I lack the 'Move Members' permission to do this!" msgstr "" -#: src/modules/pomodoro/timer.py:529 +#: src/modules/pomodoro/timer.py:537 #, possible-python-brace-format msgctxt "timer|status|stage:focus|statusline" msgid "{channel} is now in **FOCUS**! Good luck, **BREAK** starts {timestamp}" msgstr "" -#: src/modules/pomodoro/timer.py:534 +#: src/modules/pomodoro/timer.py:542 #, possible-python-brace-format msgctxt "timer|status|stage:break|statusline" msgid "" "{channel} is now on **BREAK**! Take a rest, **FOCUS** starts {timestamp}" msgstr "" -#: src/modules/pomodoro/timer.py:566 +#: src/modules/pomodoro/timer.py:574 #, possible-python-brace-format msgctxt "timer|status|warningline" msgid "" @@ -98,13 +98,13 @@ msgid "" "next stage." msgstr "" -#: src/modules/pomodoro/timer.py:585 +#: src/modules/pomodoro/timer.py:593 #, possible-python-brace-format msgctxt "timer|status|stopped:auto" msgid "Timer stopped! Join {channel} to start the timer." msgstr "" -#: src/modules/pomodoro/timer.py:590 +#: src/modules/pomodoro/timer.py:598 msgctxt "timer|status|stopped:manual" msgid "Timer stopped! Press `Start` to restart the timer." msgstr "" @@ -781,33 +781,33 @@ msgctxt "ui:timer_options|menu:voice_channel|placeholder" msgid "Set Voice Channel" msgstr "" -#: src/modules/pomodoro/ui/config.py:159 +#: src/modules/pomodoro/ui/config.py:157 msgctxt "ui:timer_options|menu:notification_channel|placeholder" msgid "Set Notification Channel" msgstr "" -#: src/modules/pomodoro/ui/config.py:174 +#: src/modules/pomodoro/ui/config.py:172 msgctxt "ui:timer_options|menu:manager_role|placeholder" msgid "Set Manager Role" msgstr "" -#: src/modules/pomodoro/ui/config.py:183 +#: src/modules/pomodoro/ui/config.py:181 #, possible-python-brace-format msgctxt "ui:timer_options|embed|title" msgid "Timer Control Panel for {channel}" msgstr "" -#: src/modules/pomodoro/ui/config.py:190 +#: src/modules/pomodoro/ui/config.py:188 msgctxt "ui:timer_options|embed|footer" msgid "Hover over the option names to view descriptions." msgstr "" -#: src/modules/pomodoro/ui/config.py:202 +#: src/modules/pomodoro/ui/config.py:200 msgctxt "ui:timer_options|embed|field:pattern|name" msgid "Pattern" msgstr "" -#: src/modules/pomodoro/ui/config.py:205 +#: src/modules/pomodoro/ui/config.py:203 #, possible-python-brace-format msgctxt "ui:timer_options|embed|field:pattern|value" msgid "" @@ -815,12 +815,12 @@ msgid "" "**`{break_len} minutes`** break" msgstr "" -#: src/modules/pomodoro/ui/config.py:216 +#: src/modules/pomodoro/ui/config.py:214 msgctxt "ui:timer_options|embed|field:channel_name|name" msgid "Channel Name Preview" msgstr "" -#: src/modules/pomodoro/ui/config.py:220 +#: src/modules/pomodoro/ui/config.py:218 #, possible-python-brace-format msgctxt "ui:timer_options|embed|field:channel_name|value" msgid "" @@ -828,35 +828,35 @@ msgid "" "(The actual channel name may not match due to ratelimits.)" msgstr "" -#: src/modules/pomodoro/ui/config.py:230 +#: src/modules/pomodoro/ui/config.py:228 msgctxt "ui:timer_options|embed|field:issues|name" msgid "Issues" msgstr "" -#: src/modules/pomodoro/ui/config.py:248 +#: src/modules/pomodoro/ui/config.py:246 msgctxt "ui:timer_options|issue:no_voice_channel" msgid "The configured voice channel does not exist! Please update it below." msgstr "" -#: src/modules/pomodoro/ui/config.py:259 +#: src/modules/pomodoro/ui/config.py:257 #, possible-python-brace-format msgctxt "ui:timer_options|issue:cannot_speak" msgid "Voice alerts are on, but I don't have speaking permissions in {channel}" msgstr "" -#: src/modules/pomodoro/ui/config.py:266 +#: src/modules/pomodoro/ui/config.py:264 #, possible-python-brace-format msgctxt "ui:timer_options|issue:cannot_change_name" msgid "" "I cannot update the name of {channel}! (Needs `MANAGE_CHANNELS` permission)" msgstr "" -#: src/modules/pomodoro/ui/config.py:277 +#: src/modules/pomodoro/ui/config.py:275 msgctxt "ui:timer_options|issue:notif_channel_dne" msgid "Configured notification channel does not exist!" msgstr "" -#: src/modules/pomodoro/ui/config.py:286 +#: src/modules/pomodoro/ui/config.py:284 #, possible-python-brace-format msgctxt "ui:timer_options|issue:notif_channel_write" msgid "" @@ -864,7 +864,7 @@ msgid "" "{channel}" msgstr "" -#: src/modules/pomodoro/ui/config.py:294 +#: src/modules/pomodoro/ui/config.py:292 #, possible-python-brace-format msgctxt "ui:timer_options|issues:cannot_make_webhooks" msgid "" diff --git a/locales/templates/babel.pot b/locales/templates/babel.pot index e3f95e32..cff6bac9 100644 --- a/locales/templates/babel.pot +++ b/locales/templates/babel.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/config.pot b/locales/templates/config.pot index cd1a99bb..b71b6253 100644 --- a/locales/templates/config.pot +++ b/locales/templates/config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,67 +17,43 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/modules/config/general.py:41 -msgctxt "guildset:timezone" -msgid "timezone" -msgstr "" - -#: src/modules/config/general.py:44 -msgctxt "guildset:timezone|desc" -msgid "Guild timezone for statistics display." -msgstr "" - -#: src/modules/config/general.py:48 -msgctxt "guildset:timezone|long_desc" -msgid "" -"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." -msgstr "" - -#: src/modules/config/general.py:62 -#, possible-python-brace-format -msgctxt "guildset:timezone|response" -msgid "The guild timezone has been set to `{timezone}`." -msgstr "" - -#: src/modules/config/general.py:94 +#: src/modules/config/cog.py:56 src/modules/config/general.py:52 msgctxt "cmd:configure_general" msgid "general" msgstr "" -#: src/modules/config/general.py:95 +#: src/modules/config/cog.py:57 src/modules/config/general.py:53 msgctxt "cmd:configure_general|desc" msgid "General configuration panel" msgstr "" -#: src/modules/config/general.py:129 +#: src/modules/config/general.py:91 msgctxt "cmd:configure_general|parse_failure:timezone" msgid "Could not set the timezone!" msgstr "" -#: src/modules/config/general.py:150 +#: src/modules/config/general.py:112 msgctxt "cmd:configure_general|success" msgid "Settings Updated!" msgstr "" -#: src/modules/config/general.py:165 +#: src/modules/config/general.py:127 msgctxt "cmd:configure_general|panel|title" msgid "General Configuration Panel" msgstr "" -#: src/modules/config/dashboard.py:98 +#: src/modules/config/dashboard.py:99 #, possible-python-brace-format msgctxt "ui:dashboard|title" msgid "Guild Dashboard (Page {page}/{total})" msgstr "" -#: src/modules/config/dashboard.py:109 +#: src/modules/config/dashboard.py:110 msgctxt "ui:dashboard|footer" msgid "Hover over setting names for a brief description" msgstr "" -#: src/modules/config/dashboard.py:172 +#: src/modules/config/dashboard.py:173 msgctxt "ui:dashboard|menu:config|placeholder" msgid "Open Configuration Panel" msgstr "" diff --git a/locales/templates/core_config.pot b/locales/templates/core_config.pot index ed18d798..bcac2021 100644 --- a/locales/templates/core_config.pot +++ b/locales/templates/core_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/economy.pot b/locales/templates/economy.pot index 7c5a37ec..07e2c264 100644 --- a/locales/templates/economy.pot +++ b/locales/templates/economy.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,88 +18,88 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: src/modules/economy/settingui.py:33 +#: src/modules/economy/settingui.py:34 msgctxt "ui:economy_config|embed|title" msgid "Economy Configuration Panel" msgstr "" -#: src/modules/economy/settingui.py:68 +#: src/modules/economy/settingui.py:67 msgctxt "dash:economy|title" msgid "Economy Configuration ({commands[configure economy]})" msgstr "" -#: src/modules/economy/settingui.py:72 +#: src/modules/economy/settingui.py:71 msgctxt "dash:economy|dropdown|placeholder" msgid "Economy Panel" msgstr "" #. ----- Economy group commands ----- -#: src/modules/economy/cog.py:86 +#: src/modules/economy/cog.py:87 msgctxt "cmd:economy" msgid "economy" msgstr "" -#: src/modules/economy/cog.py:92 +#: src/modules/economy/cog.py:93 msgctxt "cmd:economy_balance" msgid "balance" msgstr "" -#: src/modules/economy/cog.py:95 +#: src/modules/economy/cog.py:96 msgctxt "cmd:economy_balance|desc" msgid "Display or modify LionCoin balance for members and roles." msgstr "" -#: src/modules/economy/cog.py:99 +#: src/modules/economy/cog.py:100 msgctxt "cmd:economy_balance|param:target" msgid "target" msgstr "" -#: src/modules/economy/cog.py:100 +#: src/modules/economy/cog.py:101 msgctxt "cmd:economy_balance|param:add" msgid "add" msgstr "" -#: src/modules/economy/cog.py:101 +#: src/modules/economy/cog.py:102 msgctxt "cmd:economy_balance|param:set" msgid "set" msgstr "" -#: src/modules/economy/cog.py:106 +#: src/modules/economy/cog.py:107 msgctxt "cmd:economy_balance|param:target|desc" msgid "" "Target user or role to view or update. Use @everyone to update the entire " "guild." msgstr "" -#: src/modules/economy/cog.py:110 +#: src/modules/economy/cog.py:111 msgctxt "cmd:economy_balance|param:add|desc" msgid "" "Number of LionCoins to add to the target member's balance. May be negative " "to remove." msgstr "" -#: src/modules/economy/cog.py:114 +#: src/modules/economy/cog.py:115 msgctxt "cmd:economy_balance|param:set|set" msgid "New balance to set the target's balance to." msgstr "" -#: src/modules/economy/cog.py:152 +#: src/modules/economy/cog.py:153 #, possible-python-brace-format msgctxt "cmd:economy_balance|error:no_target" msgid "There are no valid members in {role.mention}! It has a total of `0` LC." msgstr "" -#: src/modules/economy/cog.py:163 +#: src/modules/economy/cog.py:164 msgctxt "cmd:economy_balance|error:target_is_bot" msgid "Bots cannot have coin balances!" msgstr "" -#: src/modules/economy/cog.py:174 +#: src/modules/economy/cog.py:175 msgctxt "cmd:economy_balance|error:args" msgid "You cannot simultaneously `set` and `add` member balances!" msgstr "" -#: src/modules/economy/cog.py:223 +#: src/modules/economy/cog.py:224 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success|affected" msgid "One user was affected." @@ -107,7 +107,7 @@ msgid_plural "**{count}** users were affected." msgstr[0] "" msgstr[1] "" -#: src/modules/economy/cog.py:229 +#: src/modules/economy/cog.py:230 #, possible-python-brace-format msgctxt "cmd:economy_balance|confirm|affected" msgid "One user will be affected." @@ -115,17 +115,17 @@ msgid_plural "**{count}** users will be affected." msgstr[0] "" msgstr[1] "" -#: src/modules/economy/cog.py:236 +#: src/modules/economy/cog.py:237 msgctxt "cmd:economy_balance|confirm|button:confirm" msgid "Yes, adjust balances" msgstr "" -#: src/modules/economy/cog.py:240 +#: src/modules/economy/cog.py:241 msgctxt "cmd:economy_balance|confirm|button:cancel" msgid "No, cancel" msgstr "" -#: src/modules/economy/cog.py:247 +#: src/modules/economy/cog.py:248 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_set|desc" msgid "" @@ -133,14 +133,14 @@ msgid "" "**{amount}**." msgstr "" -#: src/modules/economy/cog.py:256 +#: src/modules/economy/cog.py:257 #, possible-python-brace-format msgctxt "cmd:economy_balance|confirm_set|desc" msgid "" "Are you sure you want to set everyone's balance to {coin_emoji}**{amount}**?" msgstr "" -#: src/modules/economy/cog.py:264 +#: src/modules/economy/cog.py:265 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_set|desc" msgid "" @@ -148,7 +148,7 @@ msgid "" "**{amount}**." msgstr "" -#: src/modules/economy/cog.py:273 +#: src/modules/economy/cog.py:274 #, possible-python-brace-format msgctxt "cmd:economy_balance|confirm_set|desc" msgid "" @@ -156,32 +156,43 @@ msgid "" "{coin_emoji}**{amount}**?" msgstr "" -#: src/modules/economy/cog.py:290 +#: src/modules/economy/cog.py:291 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_set|desc" msgid "{user_mention} now has a balance of {coin_emoji}**{amount}**." msgstr "" -#: src/modules/economy/cog.py:306 +#: src/modules/economy/cog.py:305 +msgctxt "eventlog|event:economy_set|title" +msgid "Moderator Set Economy Balance" +msgstr "" + +#: src/modules/economy/cog.py:309 +#, possible-python-brace-format +msgctxt "eventlog|event:economy_set|desc" +msgid "{moderator} set {target}'s balance to {amount}." +msgstr "" + +#: src/modules/economy/cog.py:321 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_add|desc" msgid "" "All members of **{guild_name}** have been given {coin_emoji}**{amount}**." msgstr "" -#: src/modules/economy/cog.py:315 +#: src/modules/economy/cog.py:330 #, possible-python-brace-format msgctxt "cmd:economy_balance|confirm_add|desc" msgid "Are you sure you want to add **{amount}** to everyone's balance?" msgstr "" -#: src/modules/economy/cog.py:323 +#: src/modules/economy/cog.py:338 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_add|desc" msgid "All members of {role_mention} have been given {coin_emoji}**{amount}**." msgstr "" -#: src/modules/economy/cog.py:332 +#: src/modules/economy/cog.py:347 #, possible-python-brace-format msgctxt "cmd:economy_balance|confirm_add|desc" msgid "" @@ -189,7 +200,7 @@ msgid "" "{role_mention}?" msgstr "" -#: src/modules/economy/cog.py:354 +#: src/modules/economy/cog.py:369 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:success_add|desc" msgid "" @@ -197,26 +208,37 @@ msgid "" "{coin_emoji}**{new_amount}**." msgstr "" -#: src/modules/economy/cog.py:365 +#: src/modules/economy/cog.py:380 +msgctxt "eventlog|event:economy_add|title" +msgid "Moderator Modified Economy Balance" +msgstr "" + +#: src/modules/economy/cog.py:384 +#, possible-python-brace-format +msgctxt "eventlog|event:economy_set|desc" +msgid "{moderator} added {amount} to {target}'s balance." +msgstr "" + +#: src/modules/economy/cog.py:394 msgctxt "cmd:economy_balance|embed:success|title" msgid "Account successfully updated." msgid_plural "Accounts successfully updated." msgstr[0] "" msgstr[1] "" -#: src/modules/economy/cog.py:409 +#: src/modules/economy/cog.py:438 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|author" msgid "Balance sheet for {name}" msgstr "" -#: src/modules/economy/cog.py:415 +#: src/modules/economy/cog.py:444 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|header" msgid "This server has a total balance of {coin_emoji}**{total}**." msgstr "" -#: src/modules/economy/cog.py:423 +#: src/modules/economy/cog.py:452 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|header" msgid "" @@ -224,66 +246,66 @@ msgid "" "balance of {coin_emoji}**{total}**." msgstr "" -#: src/modules/economy/cog.py:435 +#: src/modules/economy/cog.py:464 msgctxt "cmd:economy_balance|embed:role_lb|row_format" msgid "`[{pos:>{numwidth}}]` | `{coins:>{coinwidth}} LC` | {mention}" msgstr "" -#: src/modules/economy/cog.py:463 +#: src/modules/economy/cog.py:492 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|footer" msgid "Page {page}/{total}" msgstr "" -#: src/modules/economy/cog.py:473 +#: src/modules/economy/cog.py:502 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|header" msgid "This server has a total balance of {coin_emoji}**0**." msgstr "" -#: src/modules/economy/cog.py:480 +#: src/modules/economy/cog.py:509 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:role_lb|header" msgid "The role {role_mention} has a total balance of {coin_emoji}**0**." msgstr "" -#: src/modules/economy/cog.py:500 +#: src/modules/economy/cog.py:529 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:single|desc" msgid "{mention} currently owns {coin_emoji} {coins}." msgstr "" -#: src/modules/economy/cog.py:510 +#: src/modules/economy/cog.py:539 #, possible-python-brace-format msgctxt "cmd:economy_balance|embed:single|author" msgid "Balance statement for {user}" msgstr "" -#: src/modules/economy/cog.py:519 +#: src/modules/economy/cog.py:548 msgctxt "cmd:economy_reset" msgid "reset" msgstr "" -#: src/modules/economy/cog.py:522 +#: src/modules/economy/cog.py:551 msgctxt "cmd:economy_reset|desc" msgid "" "Reset the coin balance for a target user or role. (See also \"economy " "balance\".)" msgstr "" -#: src/modules/economy/cog.py:526 +#: src/modules/economy/cog.py:555 msgctxt "cmd:economy_reset|param:target" msgid "target" msgstr "" -#: src/modules/economy/cog.py:531 +#: src/modules/economy/cog.py:560 msgctxt "cmd:economy_reset|param:target|desc" msgid "" "Target user or role to view or update. Use @everyone to reset the entire " "guild." msgstr "" -#: src/modules/economy/cog.py:558 +#: src/modules/economy/cog.py:587 #, possible-python-brace-format msgctxt "cmd:economy_reset|confirm:reset_guild|desc" msgid "" @@ -292,17 +314,17 @@ msgid "" "*This is not reversible!*" msgstr "" -#: src/modules/economy/cog.py:566 +#: src/modules/economy/cog.py:595 msgctxt "cmd:economy_reset|confirm:reset_guild|button:confirm" msgid "Yes, reset the economy" msgstr "" -#: src/modules/economy/cog.py:570 +#: src/modules/economy/cog.py:599 msgctxt "cmd:economy_reset|confirm:reset_guild|button:cancel" msgid "Cancel reset" msgstr "" -#: src/modules/economy/cog.py:586 +#: src/modules/economy/cog.py:615 #, possible-python-brace-format msgctxt "cmd:economy_reset|embed:success_guild|desc" msgid "" @@ -310,13 +332,13 @@ msgid "" "**{amount}**." msgstr "" -#: src/modules/economy/cog.py:603 +#: src/modules/economy/cog.py:632 #, possible-python-brace-format msgctxt "cmd:economy_reset|error:no_target|desc" msgid "The role {mention} has no members to reset!" msgstr "" -#: src/modules/economy/cog.py:613 +#: src/modules/economy/cog.py:642 #, possible-python-brace-format msgctxt "cmd:economy_reset|confirm:reset_role|desc" msgid "" @@ -324,17 +346,17 @@ msgid "" "**{count}** members will be affected." msgstr "" -#: src/modules/economy/cog.py:622 +#: src/modules/economy/cog.py:651 msgctxt "cmd:economy_reset|confirm:reset_role|button:confirm" msgid "Yes, complete economy reset" msgstr "" -#: src/modules/economy/cog.py:626 +#: src/modules/economy/cog.py:655 msgctxt "cmd:economy_reset|confirm:reset_role|button:cancel" msgid "Cancel" msgstr "" -#: src/modules/economy/cog.py:643 +#: src/modules/economy/cog.py:672 #, possible-python-brace-format msgctxt "cmd:economy_reset|embed:success_role|desc" msgid "" @@ -342,72 +364,72 @@ msgid "" "**{amount}**." msgstr "" -#: src/modules/economy/cog.py:665 +#: src/modules/economy/cog.py:694 #, possible-python-brace-format msgctxt "cmd:economy_reset|embed:success_user|desc" msgid "{mention}'s balance has been reset to {coin_emoji}**{amount}**." msgstr "" -#: src/modules/economy/cog.py:675 +#: src/modules/economy/cog.py:704 msgctxt "cmd:send" msgid "send" msgstr "" -#: src/modules/economy/cog.py:678 +#: src/modules/economy/cog.py:707 msgctxt "cmd:send|desc" msgid "Gift the target user a certain number of LionCoins." msgstr "" -#: src/modules/economy/cog.py:682 +#: src/modules/economy/cog.py:711 msgctxt "cmd:send|param:target" msgid "target" msgstr "" -#: src/modules/economy/cog.py:683 +#: src/modules/economy/cog.py:712 msgctxt "cmd:send|param:amount" msgid "amount" msgstr "" -#: src/modules/economy/cog.py:684 +#: src/modules/economy/cog.py:713 msgctxt "cmd:send|param:note" msgid "note" msgstr "" -#: src/modules/economy/cog.py:687 +#: src/modules/economy/cog.py:716 msgctxt "cmd:send|param:target|desc" msgid "User to send the gift to" msgstr "" -#: src/modules/economy/cog.py:688 +#: src/modules/economy/cog.py:717 msgctxt "cmd:send|param:amount|desc" msgid "Number of coins to send" msgstr "" -#: src/modules/economy/cog.py:689 +#: src/modules/economy/cog.py:718 msgctxt "cmd:send|param:note|desc" msgid "Optional note to add to the gift." msgstr "" -#: src/modules/economy/cog.py:713 +#: src/modules/economy/cog.py:742 msgctxt "cmd:send|error:not_allowed" msgid "Sorry, this server has disabled LionCoin transfers!" msgstr "" -#: src/modules/economy/cog.py:722 +#: src/modules/economy/cog.py:751 msgctxt "cmd:send|error:sending-to-self" msgid "" "What is this, tax evasion?\n" "(You can not send coins to yourself.)" msgstr "" -#: src/modules/economy/cog.py:732 +#: src/modules/economy/cog.py:761 msgctxt "cmd:send|error:sending-to-leo" msgid "" "I appreciate it, but you need it more than I do!\n" "(You cannot send coins to bots.)" msgstr "" -#: src/modules/economy/cog.py:742 +#: src/modules/economy/cog.py:771 #, possible-python-brace-format msgctxt "cmd:send|error:sending-to-bot" msgid "" @@ -416,7 +438,7 @@ msgid "" "(You cannot send coins to bots.)" msgstr "" -#: src/modules/economy/cog.py:770 +#: src/modules/economy/cog.py:799 #, possible-python-brace-format msgctxt "cmd:send|error:insufficient" msgid "" @@ -424,19 +446,30 @@ msgid "" "`Current Balance:` {coin_emoji}{balance}" msgstr "" -#: src/modules/economy/cog.py:791 +#: src/modules/economy/cog.py:816 +msgctxt "eventlog|event:send|title" +msgid "Coins Transferred" +msgstr "" + +#: src/modules/economy/cog.py:820 +#, possible-python-brace-format +msgctxt "eventlog|event:send|desc" +msgid "{source} gifted {amount} to {target}" +msgstr "" + +#: src/modules/economy/cog.py:833 #, possible-python-brace-format msgctxt "cmd:send|embed:gift|title" msgid "{user} sent you a gift!" msgstr "" -#: src/modules/economy/cog.py:795 +#: src/modules/economy/cog.py:837 #, possible-python-brace-format msgctxt "cmd:send|embed:gift|desc" msgid "{mention} sent you {coin_emoji}**{amount}**." msgstr "" -#: src/modules/economy/cog.py:820 +#: src/modules/economy/cog.py:862 #, possible-python-brace-format msgctxt "cmd:send|embed:ack|desc" msgid "" @@ -444,85 +477,113 @@ msgid "" "{mention}!" msgstr "" -#: src/modules/economy/cog.py:830 +#: src/modules/economy/cog.py:872 msgctxt "cmd:send|embed:ack|desc|error:unreachable" msgid "" "Unfortunately, I was not able to message the recipient. Perhaps they have me " "blocked?" msgstr "" -#: src/modules/economy/cog.py:842 +#: src/modules/economy/cog.py:884 msgctxt "cmd:configure_economy" msgid "economy" msgstr "" -#: src/modules/economy/cog.py:845 +#: src/modules/economy/cog.py:887 msgctxt "cmd:configure_economy|desc" msgid "Configure LionCoin Economy" msgstr "" -#: src/modules/economy/settings.py:35 +#: src/modules/economy/settings.py:36 msgctxt "guildset:coins_per_xp" msgid "coins_per_100xp" msgstr "" -#: src/modules/economy/settings.py:38 +#: src/modules/economy/settings.py:39 msgctxt "guildset:coins_per_xp|desc" msgid "How many LionCoins to reward members per 100 XP they earn." msgstr "" -#: src/modules/economy/settings.py:42 +#: src/modules/economy/settings.py:43 msgctxt "guildset:coins_per_xp|long_desc" msgid "" "Members will be rewarded with this many LionCoins for every 100 XP they earn." msgstr "" -#: src/modules/economy/settings.py:46 +#: src/modules/economy/settings.py:47 msgctxt "guildset:coins_per_xp|long_desc" msgid "The number of coins to reward per 100 XP." msgstr "" -#: src/modules/economy/settings.py:59 +#: src/modules/economy/settings.py:60 #, possible-python-brace-format msgctxt "guildset:coins_per_xp|set_response" msgid "" "For every **100** XP they earn, members will now be given {coin}**{amount}**." msgstr "" -#: src/modules/economy/settings.py:70 +#: src/modules/economy/settings.py:71 msgctxt "guildset:allow_transfers" msgid "allow_transfers" msgstr "" -#: src/modules/economy/settings.py:73 +#: src/modules/economy/settings.py:74 msgctxt "guildset:allow_transfers|desc" msgid "Whether to allow members to transfer LionCoins to each other." msgstr "" -#: src/modules/economy/settings.py:77 +#: src/modules/economy/settings.py:78 msgctxt "guildset:allow_transfers|long_desc" msgid "" "If disabled, members will not be able to transfer LionCoins to each other." msgstr "" -#: src/modules/economy/settings.py:85 +#: src/modules/economy/settings.py:86 msgctxt "guildset:allow_transfers|outputs:true" msgid "Enabled (Coin transfers allowed.)" msgstr "" -#: src/modules/economy/settings.py:86 +#: src/modules/economy/settings.py:87 msgctxt "guildset:allow_transfers|outputs:false" msgid "Disabled (Coin transfers not allowed.)" msgstr "" -#: src/modules/economy/settings.py:102 +#: src/modules/economy/settings.py:103 #, possible-python-brace-format msgctxt "guildset:allow_transfers|set_response|set:true" msgid "Members will now be able to use {send_cmd} to transfer {coin}" msgstr "" -#: src/modules/economy/settings.py:107 +#: src/modules/economy/settings.py:108 #, possible-python-brace-format msgctxt "guildset:allow_transfers|set_response|set:false" msgid "Members will not be able to use {send_cmd} to transfer {coin}" msgstr "" + +#: src/modules/economy/settings.py:119 +msgctxt "guildset:starting_funds" +msgid "starting_funds" +msgstr "" + +#: src/modules/economy/settings.py:122 +msgctxt "guildset:starting_funds|desc" +msgid "How many LionCoins should a member start with." +msgstr "" + +#: src/modules/economy/settings.py:126 +msgctxt "guildset:starting_funds|long_desc" +msgid "" +"Members will be given this number of coins when they first interact with me, " +"or first join the server." +msgstr "" + +#: src/modules/economy/settings.py:130 +msgctxt "guildset:starting_funds|accepts" +msgid "Number of coins to give to new members." +msgstr "" + +#: src/modules/economy/settings.py:142 +#, possible-python-brace-format +msgctxt "guildset:starting_funds|set_response" +msgid "New members will now start with {amount}" +msgstr "" diff --git a/locales/templates/exec.pot b/locales/templates/exec.pot index 88ec3c89..59b360ea 100644 --- a/locales/templates/exec.pot +++ b/locales/templates/exec.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/goals-gui.pot b/locales/templates/goals-gui.pot index bcf4ff2e..b5d9ce6d 100644 --- a/locales/templates/goals-gui.pot +++ b/locales/templates/goals-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/leaderboard-gui.pot b/locales/templates/leaderboard-gui.pot index 2d908d18..96204936 100644 --- a/locales/templates/leaderboard-gui.pot +++ b/locales/templates/leaderboard-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/lion-core.pot b/locales/templates/lion-core.pot index 0dfdc27e..faf15d12 100644 --- a/locales/templates/lion-core.pot +++ b/locales/templates/lion-core.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,6 +17,79 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" +#: src/core/lion_guild.py:31 +msgctxt "eventlog|field:start|name" +msgid "Start" +msgstr "" + +#: src/core/lion_guild.py:36 +msgctxt "eventlog|field:expiry|name" +msgid "Expires" +msgstr "" + +#: src/core/lion_guild.py:41 +msgctxt "eventlog|field:roles_given|name" +msgid "Roles Given" +msgstr "" + +#: src/core/lion_guild.py:46 +msgctxt "eventlog|field:roles_given|name" +msgid "Roles Taken" +msgstr "" + +#: src/core/lion_guild.py:51 +msgctxt "eventlog|field:coins_earned|name" +msgid "Coins Earned" +msgstr "" + +#: src/core/lion_guild.py:56 +msgctxt "eventlog|field:price|name" +msgid "Price" +msgstr "" + +#: src/core/lion_guild.py:61 +msgctxt "eventlog|field:balance|name" +msgid "Balance" +msgstr "" + +#: src/core/lion_guild.py:66 +msgctxt "eventlog|field:refund|name" +msgid "Coins Refunded" +msgstr "" + +#: src/core/lion_guild.py:71 +msgctxt "eventlog|field:memberid|name" +msgid "Member" +msgstr "" + +#: src/core/lion_guild.py:76 +msgctxt "eventlog|field:channelid|name" +msgid "Channel" +msgstr "" + +#: src/core/lion_guild.py:208 +msgctxt "eventlog|error:manage_webhooks" +msgid "" +"This channel is configured as an event log, but I am missing the 'Manage " +"Webhooks' permission here." +msgstr "" + +#: src/core/lion_guild.py:220 +#, possible-python-brace-format +msgctxt "eventlog|create|name" +msgid "{bot_name} Event Log" +msgstr "" + +#: src/core/lion_guild.py:224 +msgctxt "eventlog|create|audit_reason" +msgid "Creating event log webhook" +msgstr "" + +#: src/core/lion_guild.py:342 +msgctxt "eventlog|field:errors|name" +msgid "Errors" +msgstr "" + #: src/core/setting_types.py:31 msgctxt "settype:coin|accepts" msgid "A positive integral number of coins." diff --git a/locales/templates/member_admin.pot b/locales/templates/member_admin.pot index 02fa3378..edc820f0 100644 --- a/locales/templates/member_admin.pot +++ b/locales/templates/member_admin.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -67,39 +67,92 @@ msgctxt "dash:member_admin|section:initial_roles|name" msgid "Initial Roles ({commands[configure welcome]})" msgstr "" -#: src/modules/member_admin/cog.py:239 +#: src/modules/member_admin/cog.py:119 +msgctxt "eventlog|event:welcome|title" +msgid "New Member Joined" +msgstr "" + +#: src/modules/member_admin/cog.py:123 +#, possible-python-brace-format +msgctxt "eventlog|event:welcome|desc" +msgid "{member} joined the server for the first time." +msgstr "" + +#: src/modules/member_admin/cog.py:207 +msgctxt "eventlog|event:returning|title" +msgid "Member Rejoined" +msgstr "" + +#: src/modules/member_admin/cog.py:211 +#, possible-python-brace-format +msgctxt "eventlog|event:returning|desc" +msgid "{member} rejoined the server." +msgstr "" + +#: src/modules/member_admin/cog.py:220 +msgctxt "eventlog|event:returning|field:first_joined" +msgid "First Joined" +msgstr "" + +#: src/modules/member_admin/cog.py:227 +msgctxt "eventlog|event:returning|field:last_seen" +msgid "Last Seen" +msgstr "" + +#: src/modules/member_admin/cog.py:275 +msgctxt "eventlog|event:left|title" +msgid "Member Left" +msgstr "" + +#: src/modules/member_admin/cog.py:279 +#, possible-python-brace-format +msgctxt "eventlog|event:left|desc" +msgid "{member} left the server." +msgstr "" + +#: src/modules/member_admin/cog.py:287 +msgctxt "eventlog|event:left|field:stored_roles" +msgid "Stored Roles" +msgstr "" + +#: src/modules/member_admin/cog.py:294 +msgctxt "eventlog|event:left|field:first_joined" +msgid "First Joined" +msgstr "" + +#: src/modules/member_admin/cog.py:324 msgctxt "cmd:resetmember" msgid "resetmember" msgstr "" -#: src/modules/member_admin/cog.py:242 +#: src/modules/member_admin/cog.py:327 msgctxt "cmd:resetmember|desc" msgid "Reset (server-associated) member data for the target member or user." msgstr "" -#: src/modules/member_admin/cog.py:246 +#: src/modules/member_admin/cog.py:331 msgctxt "cmd:resetmember|param:target" msgid "member_to_reset" msgstr "" -#: src/modules/member_admin/cog.py:247 +#: src/modules/member_admin/cog.py:332 msgctxt "cmd:resetmember|param:saved_roles" msgid "saved_roles" msgstr "" -#: src/modules/member_admin/cog.py:252 +#: src/modules/member_admin/cog.py:337 msgctxt "cmd:resetmember|param:target|desc" msgid "Choose the member (current or past) you want to reset." msgstr "" -#: src/modules/member_admin/cog.py:256 +#: src/modules/member_admin/cog.py:341 msgctxt "cmd:resetmember|param:saved_roles|desc" msgid "" "Clear the saved roles for this member, so their past roles are not restored " "on rejoin." msgstr "" -#: src/modules/member_admin/cog.py:283 +#: src/modules/member_admin/cog.py:368 #, possible-python-brace-format msgctxt "cmd:resetmember|reset:saved_roles|success" msgid "" @@ -107,17 +160,17 @@ msgid "" "roles if they rejoin." msgstr "" -#: src/modules/member_admin/cog.py:291 +#: src/modules/member_admin/cog.py:376 msgctxt "cmd:resetmember|error:nothing_to_do" msgid "No reset operation selected, nothing to do." msgstr "" -#: src/modules/member_admin/cog.py:307 +#: src/modules/member_admin/cog.py:392 msgctxt "cmd:configure_welcome" msgid "welcome" msgstr "" -#: src/modules/member_admin/cog.py:310 +#: src/modules/member_admin/cog.py:395 msgctxt "cmd:configure_welcome|desc" msgid "Configure new member greetings and roles." msgstr "" diff --git a/locales/templates/meta.pot b/locales/templates/meta.pot index 67158691..8fff62d4 100644 --- a/locales/templates/meta.pot +++ b/locales/templates/meta.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/moderation.pot b/locales/templates/moderation.pot index eeaaa78d..3b01d6a5 100644 --- a/locales/templates/moderation.pot +++ b/locales/templates/moderation.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -57,34 +57,34 @@ msgctxt "cmd:configure_moderation|desc" msgid "Configure general moderation settings." msgstr "" -#: src/modules/moderation/ticket.py:147 +#: src/modules/moderation/ticket.py:148 #, possible-python-brace-format msgctxt "ticket|title:auto" msgid "Ticket #{ticketid} | {state} | {type}[Auto] | {name}" msgstr "" -#: src/modules/moderation/ticket.py:152 +#: src/modules/moderation/ticket.py:153 #, possible-python-brace-format msgctxt "ticket|title:manual" msgid "Ticket #{ticketid} | {state} | {type} | {name}" msgstr "" -#: src/modules/moderation/ticket.py:168 +#: src/modules/moderation/ticket.py:169 msgctxt "ticket|field:target|name" msgid "Target" msgstr "" -#: src/modules/moderation/ticket.py:173 +#: src/modules/moderation/ticket.py:174 msgctxt "ticket|field:moderator|name" msgid "Moderator" msgstr "" -#: src/modules/moderation/ticket.py:180 +#: src/modules/moderation/ticket.py:181 msgctxt "ticket|field:expiry|mode:expiring|name" msgid "Expires At" msgstr "" -#: src/modules/moderation/ticket.py:183 +#: src/modules/moderation/ticket.py:184 #, possible-python-brace-format msgctxt "ticket|field:expiry|mode:expiring|value" msgid "" @@ -92,44 +92,44 @@ msgid "" "Duration: `{duration}`" msgstr "" -#: src/modules/moderation/ticket.py:191 +#: src/modules/moderation/ticket.py:192 msgctxt "ticket|field:expiry|mode:expired|name" msgid "Expired" msgstr "" -#: src/modules/moderation/ticket.py:194 +#: src/modules/moderation/ticket.py:195 #, possible-python-brace-format msgctxt "ticket|field:expiry|mode:expired|value" msgid "{timestamp}" msgstr "" -#: src/modules/moderation/ticket.py:201 +#: src/modules/moderation/ticket.py:202 msgctxt "ticket|field:expiry|mode:open|name" msgid "Expiry" msgstr "" -#: src/modules/moderation/ticket.py:204 +#: src/modules/moderation/ticket.py:205 #, possible-python-brace-format msgctxt "ticket|field:expiry|mode:open|value" msgid "{timestamp}" msgstr "" -#: src/modules/moderation/ticket.py:212 +#: src/modules/moderation/ticket.py:213 msgctxt "ticket|field:context|name" msgid "Context" msgstr "" -#: src/modules/moderation/ticket.py:219 +#: src/modules/moderation/ticket.py:220 msgctxt "ticket|field:notes|name" msgid "Notes" msgstr "" -#: src/modules/moderation/ticket.py:226 +#: src/modules/moderation/ticket.py:227 msgctxt "ticket|field:pardoned|name" msgid "Pardoned" msgstr "" -#: src/modules/moderation/ticket.py:229 +#: src/modules/moderation/ticket.py:230 #, possible-python-brace-format msgctxt "ticket|field:pardoned|value" msgid "" diff --git a/locales/templates/monthly-gui.pot b/locales/templates/monthly-gui.pot index 5536f040..23ca24bd 100644 --- a/locales/templates/monthly-gui.pot +++ b/locales/templates/monthly-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/profile-gui.pot b/locales/templates/profile-gui.pot index 44f98a7e..f2e03b40 100644 --- a/locales/templates/profile-gui.pot +++ b/locales/templates/profile-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/ranks.pot b/locales/templates/ranks.pot index dbdd0f3b..81f0ba52 100644 --- a/locales/templates/ranks.pot +++ b/locales/templates/ranks.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -32,22 +32,91 @@ msgctxt "cmd:configure_ranks|param:rank_type|choice:message" msgid "Message" msgstr "" -#: src/modules/ranks/cog.py:498 +#: src/modules/ranks/cog.py:351 +#, possible-python-brace-format +msgctxt "eventlog|event:rank_check|error:remove_failed" +msgid "Failed to remove old rank roles: `{error}`" +msgstr "" + +#: src/modules/ranks/cog.py:375 +#, possible-python-brace-format +msgctxt "eventlog|event:rank_check|error:add_failed" +msgid "Failed to add new rank role: `{error}`" +msgstr "" + +#: src/modules/ranks/cog.py:380 +msgctxt "eventlog|event:rank_check|error:add_impossible" +msgid "" +"Could not assign new activity rank role. Lacking permissions or invalid role." +msgstr "" + +#: src/modules/ranks/cog.py:386 +msgctxt "eventlog|event:rank_check|error:permissions" +msgid "" +"Could not update activity rank roles, I lack the 'Manage Roles' permission." +msgstr "" + +#: src/modules/ranks/cog.py:398 +msgctxt "eventlog|event:rank_check|name" +msgid "Member Activity Rank Roles Updated" +msgstr "" + +#: src/modules/ranks/cog.py:478 +#, possible-python-brace-format +msgctxt "eventlog|event:new_rank|error:remove_failed" +msgid "Failed to remove old rank roles: `{error}`" +msgstr "" + +#: src/modules/ranks/cog.py:502 +#, possible-python-brace-format +msgctxt "eventlog|event:new_rank|error:add_failed" +msgid "Failed to add new rank role: `{error}`" +msgstr "" + +#: src/modules/ranks/cog.py:507 +msgctxt "eventlog|event:new_rank|error:add_impossible" +msgid "" +"Could not assign new activity rank role. Lacking permissions or invalid role." +msgstr "" + +#: src/modules/ranks/cog.py:513 +msgctxt "eventlog|event:new_rank|error:permissions" +msgid "" +"Could not update activity rank roles, I lack the 'Manage Roles' permission." +msgstr "" + +#: src/modules/ranks/cog.py:548 +msgctxt "eventlog|event:new_rank|error:notify_failed" +msgid "Could not notify member." +msgstr "" + +#: src/modules/ranks/cog.py:555 +msgctxt "eventlog|event:new_rank|name" +msgid "Member Achieved Activity rank" +msgstr "" + +#: src/modules/ranks/cog.py:559 +#, possible-python-brace-format +msgctxt "eventlog|event:new_rank|desc" +msgid "{member} earned the new activity rank {rank}" +msgstr "" + +#: src/modules/ranks/cog.py:587 msgctxt "event:rank_update|embed:notify" msgid "New Activity Rank Attained!" msgstr "" -#: src/modules/ranks/cog.py:607 +#: src/modules/ranks/cog.py:692 msgctxt "rank_refresh|error:cannot_chunk|desc" msgid "Could not retrieve member list from Discord. Please try again later." msgstr "" -#: src/modules/ranks/cog.py:620 +#: src/modules/ranks/cog.py:705 msgctxt "rank_refresh|error:roles_dne|desc" msgid "Some ranks have invalid or deleted roles! Please remove them first." msgstr "" -#: src/modules/ranks/cog.py:630 +#: src/modules/ranks/cog.py:715 #, possible-python-brace-format msgctxt "rank_refresh|error:unassignable_roles|desc" msgid "" @@ -55,59 +124,73 @@ msgid "" "{roles}" msgstr "" -#: src/modules/ranks/cog.py:700 +#: src/modules/ranks/cog.py:785 msgctxt "rank_refresh|remove_roles|audit" msgid "Removing invalid rank role." msgstr "" -#: src/modules/ranks/cog.py:714 +#: src/modules/ranks/cog.py:799 #, possible-python-brace-format msgctxt "rank_refresh|remove_roles|small_error" msgid "*Could not remove ranks from {member}*" msgstr "" -#: src/modules/ranks/cog.py:721 +#: src/modules/ranks/cog.py:806 msgctxt "rank_refresh|remove_roles|error:too_many_issues" msgid "" "Too many issues occurred while removing ranks! Please check my permissions " "and try again in a few minutes." msgstr "" -#: src/modules/ranks/cog.py:735 +#: src/modules/ranks/cog.py:820 msgctxt "rank_refresh|add_roles|audit" msgid "Adding rank role from refresh" msgstr "" -#: src/modules/ranks/cog.py:749 +#: src/modules/ranks/cog.py:834 #, possible-python-brace-format msgctxt "rank_refresh|add_roles|small_error" msgid "*Could not add {role} to {member}*" msgstr "" -#: src/modules/ranks/cog.py:756 +#: src/modules/ranks/cog.py:841 msgctxt "rank_refresh|add_roles|error:too_many_issues" msgid "" "Too many issues occurred while adding ranks! Please check my permissions and " "try again in a few minutes." msgstr "" +#: src/modules/ranks/cog.py:869 +msgctxt "eventlog|event:rank_refresh|name" +msgid "Activity Ranks Refreshed" +msgstr "" + +#: src/modules/ranks/cog.py:873 +#, possible-python-brace-format +msgctxt "eventlog|event:rank_refresh|desc" +msgid "" +"{actor} refresh member activity ranks.\n" +"**`{removed}`** invalid rank roles removed.\n" +"**`{added}`** new rank roles added." +msgstr "" + #. ---------- Commands ---------- -#: src/modules/ranks/cog.py:781 +#: src/modules/ranks/cog.py:884 msgctxt "cmd:ranks" msgid "ranks" msgstr "" -#: src/modules/ranks/cog.py:813 +#: src/modules/ranks/cog.py:916 msgctxt "cmd:configure_ranks" msgid "ranks" msgstr "" -#: src/modules/ranks/cog.py:814 +#: src/modules/ranks/cog.py:917 msgctxt "cmd:configure_ranks|desc" msgid "Configure Activity Ranks" msgstr "" -#: src/modules/ranks/cog.py:874 +#: src/modules/ranks/cog.py:977 #, possible-python-brace-format msgctxt "" "cmd:configure_ranks|response:updated|setting:notification|withdm_withchannel" @@ -116,20 +199,20 @@ msgid "" "otherwise to {channel}" msgstr "" -#: src/modules/ranks/cog.py:880 +#: src/modules/ranks/cog.py:983 msgctxt "" "cmd:configure_ranks|response:updated|setting:notification|withdm_nochannel" msgid "Rank update notifications will be sent via **direct message**." msgstr "" -#: src/modules/ranks/cog.py:886 +#: src/modules/ranks/cog.py:989 #, possible-python-brace-format msgctxt "" "cmd:configure_ranks|response:updated|setting:notification|nodm_withchannel" msgid "Rank update notifications will be sent to {channel}." msgstr "" -#: src/modules/ranks/cog.py:891 +#: src/modules/ranks/cog.py:994 msgctxt "" "cmd:configure_ranks|response:updated|setting:notification|nodm_nochannel" msgid "Members will not be notified when their activity rank updates." @@ -618,49 +701,60 @@ msgctxt "ui:rank_overview|button:auto|label" msgid "Auto Create" msgstr "" -#: src/modules/ranks/ui/overview.py:110 +#: src/modules/ranks/ui/overview.py:111 msgctxt "ui:rank_overview|button:refresh|label" msgid "Refresh Member Ranks" msgstr "" -#: src/modules/ranks/ui/overview.py:122 +#: src/modules/ranks/ui/overview.py:123 msgctxt "ui:rank_overview|button:clear|confirm" msgid "Are you sure you want to **delete all activity ranks** in this server?" msgstr "" -#: src/modules/ranks/ui/overview.py:127 +#: src/modules/ranks/ui/overview.py:128 msgctxt "ui:rank_overview|button:clear|confirm|button:yes" msgid "Yes, clear ranks" msgstr "" -#: src/modules/ranks/ui/overview.py:133 +#: src/modules/ranks/ui/overview.py:134 msgctxt "ui:rank_overview|button:clear|confirm|button:no" msgid "Cancel" msgstr "" -#: src/modules/ranks/ui/overview.py:149 +#: src/modules/ranks/ui/overview.py:150 msgctxt "ui:rank_overview|button:clear|label" msgid "Clear Ranks" msgstr "" -#: src/modules/ranks/ui/overview.py:179 +#: src/modules/ranks/ui/overview.py:164 +msgctxt "ui:rank_overview|button:create|error:my_permissions" +msgid "I lack the 'Manage Roles' permission required to create rank roles!" +msgstr "" + +#: src/modules/ranks/ui/overview.py:173 +#, possible-python-brace-format +msgctxt "ui:rank_overview|button:create|success" +msgid "Created a new rank {role}" +msgstr "" + +#: src/modules/ranks/ui/overview.py:190 msgctxt "ui:rank_overview|button:create|label" msgid "Create Rank" msgstr "" -#: src/modules/ranks/ui/overview.py:222 +#: src/modules/ranks/ui/overview.py:233 msgctxt "ui:rank_overview|menu:roles|error:not_assignable|suberror:is_default" msgid "The @everyone role cannot be removed, and cannot be a rank!" msgstr "" -#: src/modules/ranks/ui/overview.py:227 +#: src/modules/ranks/ui/overview.py:238 msgctxt "ui:rank_overview|menu:roles|error:not_assignable|suberror:is_managed" msgid "" "The role is managed by another application or integration, and cannot be a " "rank!" msgstr "" -#: src/modules/ranks/ui/overview.py:232 +#: src/modules/ranks/ui/overview.py:243 msgctxt "" "ui:rank_overview|menu:roles|error:not_assignable|suberror:no_permissions" msgid "" @@ -668,34 +762,34 @@ msgid "" "manage ranks!" msgstr "" -#: src/modules/ranks/ui/overview.py:237 +#: src/modules/ranks/ui/overview.py:248 msgctxt "ui:rank_overview|menu:roles|error:not_assignable|suberror:above_me" msgid "" "This role is above my top role in the role hierarchy, so I cannot add or " "remove it!" msgstr "" -#: src/modules/ranks/ui/overview.py:243 +#: src/modules/ranks/ui/overview.py:254 msgctxt "ui:rank_overview|menu:roles|error:not_assignable|suberror:other" msgid "I am not able to manage the selected role, so it cannot be a rank!" msgstr "" -#: src/modules/ranks/ui/overview.py:249 +#: src/modules/ranks/ui/overview.py:260 msgctxt "ui:rank_overview|menu:roles|error:not_assignable|title" msgid "Could not create rank!" msgstr "" -#: src/modules/ranks/ui/overview.py:273 +#: src/modules/ranks/ui/overview.py:284 msgctxt "ui:rank_overview|menu:roles|placeholder" msgid "Create from role" msgstr "" -#: src/modules/ranks/ui/overview.py:290 +#: src/modules/ranks/ui/overview.py:301 msgctxt "ui:rank_overview|menu:ranks|placeholder" msgid "View or edit rank" msgstr "" -#: src/modules/ranks/ui/overview.py:376 +#: src/modules/ranks/ui/overview.py:387 msgctxt "ui:rank_overview|embed:noranks|desc" msgid "" "No activity ranks have been set up!\n" @@ -703,36 +797,36 @@ msgid "" "xp ranks, or select a role or press Create below!" msgstr "" -#: src/modules/ranks/ui/overview.py:384 +#: src/modules/ranks/ui/overview.py:395 #, possible-python-brace-format msgctxt "ui:rank_overview|embed|title|type:voice" msgid "Voice Ranks in {guild_name}" msgstr "" -#: src/modules/ranks/ui/overview.py:389 +#: src/modules/ranks/ui/overview.py:400 #, possible-python-brace-format msgctxt "ui:rank_overview|embed|title|type:xp" msgid "XP ranks in {guild_name}" msgstr "" -#: src/modules/ranks/ui/overview.py:394 +#: src/modules/ranks/ui/overview.py:405 #, possible-python-brace-format msgctxt "ui:rank_overview|embed|title|type:message" msgid "Message ranks in {guild_name}" msgstr "" -#: src/modules/ranks/ui/overview.py:406 +#: src/modules/ranks/ui/overview.py:417 msgctxt "ui:rank_overview|embed|field:note|name" msgid "Note" msgstr "" -#: src/modules/ranks/ui/overview.py:412 +#: src/modules/ranks/ui/overview.py:423 #, possible-python-brace-format msgctxt "ui:rank_overview|embed|field:note|value:with_season" msgid "Ranks are determined by activity since {timestamp}." msgstr "" -#: src/modules/ranks/ui/overview.py:419 +#: src/modules/ranks/ui/overview.py:430 #, possible-python-brace-format msgctxt "ui:rank_overview|embed|field:note|value:without_season" msgid "" @@ -741,7 +835,7 @@ msgid "" "ranks) set the `season_start` with {stats_cmd}" msgstr "" -#: src/modules/ranks/ui/overview.py:426 +#: src/modules/ranks/ui/overview.py:437 msgctxt "ui:rank_overview|embed|field:note|value|voice_addendum" msgid "" "Also note that ranks will only be updated when a member leaves a tracked " diff --git a/locales/templates/reminders.pot b/locales/templates/reminders.pot index a2592ee1..77a8e9d0 100644 --- a/locales/templates/reminders.pot +++ b/locales/templates/reminders.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/rolemenus.pot b/locales/templates/rolemenus.pot index 1e8099a7..6ef56a7d 100644 --- a/locales/templates/rolemenus.pot +++ b/locales/templates/rolemenus.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,45 +18,87 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: src/modules/rolemenus/cog.py:41 +#: src/modules/rolemenus/cog.py:43 msgctxt "argtype:menu_style|opt:reaction" msgid "Reaction Roles" msgstr "" -#: src/modules/rolemenus/cog.py:45 +#: src/modules/rolemenus/cog.py:47 msgctxt "argtype:menu_style|opt:button" msgid "Button Menu" msgstr "" -#: src/modules/rolemenus/cog.py:49 +#: src/modules/rolemenus/cog.py:51 msgctxt "argtype:menu_style|opt:dropdown" msgid "Dropdown Menu" msgstr "" -#: src/modules/rolemenus/cog.py:68 +#: src/modules/rolemenus/cog.py:70 msgctxt "ctxcmd:rolemenu" msgid "Role Menu Editor" msgstr "" -#: src/modules/rolemenus/cog.py:81 +#: src/modules/rolemenus/cog.py:83 msgctxt "ctxcmd:rolemenu|error:author_perms" msgid "" "You need the `MANAGE_ROLES` permission in order to manage the server role " "menus." msgstr "" -#: src/modules/rolemenus/cog.py:88 +#: src/modules/rolemenus/cog.py:90 msgctxt "ctxcmd:rolemenus|error:my_perms" msgid "" "I lack the `MANAGE_ROLES` permission required to offer roles from role menus." msgstr "" -#: src/modules/rolemenus/cog.py:301 +#: src/modules/rolemenus/cog.py:333 +msgctxt "eventlog|event:rolemenu_role_expire|error:remove_failed" +msgid "Removed the role, but the member still has the role!!" +msgstr "" + +#: src/modules/rolemenus/cog.py:344 +msgctxt "eventlog|event:rolemenu_role_expire|error:member_gone" +msgid "Member could not be found.. role has been removed from saved roles." +msgstr "" + +#: src/modules/rolemenus/cog.py:350 +#, possible-python-brace-format +msgctxt "eventlog|event:rolemenu_role_expire|error:no_role" +msgid "Role {role} no longer exists." +msgstr "" + +#: src/modules/rolemenus/cog.py:356 +msgctxt "eventlog|event:rolemenu_role_expire|title" +msgid "Equipped role has expired" +msgstr "" + +#: src/modules/rolemenus/cog.py:360 +#, possible-python-brace-format +msgctxt "eventlog|event:rolemenu_role_expire|desc" +msgid "{member}'s role {role} has now expired." +msgstr "" + +#: src/modules/rolemenus/cog.py:368 +msgctxt "eventlog|event:rolemenu_role_expire|field:menu" +msgid "Obtained From" +msgstr "" + +#: src/modules/rolemenus/cog.py:377 +msgctxt "eventlog|event:rolemenu_role_expire|field:menu" +msgid "Obtained At" +msgstr "" + +#: src/modules/rolemenus/cog.py:384 +msgctxt "eventlog|event:rolemenu_role_expire|field:expiry" +msgid "Expiry" +msgstr "" + +#: src/modules/rolemenus/cog.py:419 msgctxt "parse:message_link|suberror:message_dne" msgid "Could not find the linked message, has it been deleted?" msgstr "" -#: src/modules/rolemenus/cog.py:306 +#: src/modules/rolemenus/cog.py:424 #, possible-python-brace-format msgctxt "parse:message_link|suberror:no_perms" msgid "" @@ -64,20 +106,20 @@ msgid "" "{channel}." msgstr "" -#: src/modules/rolemenus/cog.py:311 +#: src/modules/rolemenus/cog.py:429 #, possible-python-brace-format msgctxt "parse:message_link|suberror:channel_dne" msgid "The channel `{channelid}` could not be found in this server." msgstr "" -#: src/modules/rolemenus/cog.py:316 +#: src/modules/rolemenus/cog.py:434 msgctxt "parse:message_link|suberror:malformed_link" msgid "" "Malformed message link. Please copy the link by right clicking the target " "message." msgstr "" -#: src/modules/rolemenus/cog.py:323 +#: src/modules/rolemenus/cog.py:441 #, possible-python-brace-format msgctxt "parse:message_link|error" msgid "" @@ -85,136 +127,136 @@ msgid "" "**ERROR:** {error}" msgstr "" -#: src/modules/rolemenus/cog.py:435 +#: src/modules/rolemenus/cog.py:553 msgctxt "cmd:rolemenus" msgid "rolemenus" msgstr "" -#: src/modules/rolemenus/cog.py:438 +#: src/modules/rolemenus/cog.py:556 msgctxt "cmd:rolemenus|desc" msgid "View and configure the role menus in this server." msgstr "" -#: src/modules/rolemenus/cog.py:455 +#: src/modules/rolemenus/cog.py:573 msgctxt "cmd:rolemenus|error:author_perms" msgid "" "You need the `MANAGE_ROLES` permission in order to manage the server role " "menus." msgstr "" -#: src/modules/rolemenus/cog.py:462 +#: src/modules/rolemenus/cog.py:580 msgctxt "cmd:rolemenus|error:my_perms" msgid "" "I lack the `MANAGE_ROLES` permission required to offer roles from role menus." msgstr "" -#: src/modules/rolemenus/cog.py:497 +#: src/modules/rolemenus/cog.py:615 #, possible-python-brace-format msgctxt "acmpl:menus|choice:no_choices|name" msgid "No role menus matching '{partial}'" msgstr "" -#: src/modules/rolemenus/cog.py:516 +#: src/modules/rolemenus/cog.py:634 msgctxt "acmpl:menuroles|param:menu|keyname" msgid "menu" msgstr "" -#: src/modules/rolemenus/cog.py:522 +#: src/modules/rolemenus/cog.py:640 msgctxt "acmpl:menuroles|choice:no_menu|name" msgid "Please select a menu first" msgstr "" -#: src/modules/rolemenus/cog.py:546 +#: src/modules/rolemenus/cog.py:664 #, possible-python-brace-format msgctxt "acmpl:menuroles|choice:invalid_menu|name" msgid "Menu '{name}' does not exist!" msgstr "" -#: src/modules/rolemenus/cog.py:575 +#: src/modules/rolemenus/cog.py:693 #, possible-python-brace-format msgctxt "acmpl:menuroles|choice:no_matching|name" msgid "No roles in this menu matching '{partial}'" msgstr "" -#: src/modules/rolemenus/cog.py:582 +#: src/modules/rolemenus/cog.py:700 msgctxt "group:rolemenu" msgid "rolemenu" msgstr "" -#: src/modules/rolemenus/cog.py:585 +#: src/modules/rolemenus/cog.py:703 msgctxt "group:rolemenu|desc" msgid "Base command group for role menu configuration." msgstr "" -#: src/modules/rolemenus/cog.py:594 +#: src/modules/rolemenus/cog.py:712 msgctxt "cmd:rolemenu_create" msgid "newmenu" msgstr "" -#: src/modules/rolemenus/cog.py:597 +#: src/modules/rolemenus/cog.py:715 msgctxt "cmd:rolemenu_create|desc" msgid "Create a new role menu (optionally using an existing message)" msgstr "" -#: src/modules/rolemenus/cog.py:611 +#: src/modules/rolemenus/cog.py:729 msgctxt "cmd:rolemenu_create|param:message" msgid "message_link" msgstr "" -#: src/modules/rolemenus/cog.py:612 +#: src/modules/rolemenus/cog.py:730 msgctxt "cmd:rolemenu_create|param:menu_style" msgid "menu_style" msgstr "" -#: src/modules/rolemenus/cog.py:613 +#: src/modules/rolemenus/cog.py:731 msgctxt "cmd:rolemenu_create|param:remplate" msgid "template" msgstr "" -#: src/modules/rolemenus/cog.py:614 +#: src/modules/rolemenus/cog.py:732 msgctxt "cmd:rolemenu_create|param:rawmessage" msgid "custom_message" msgstr "" -#: src/modules/rolemenus/cog.py:624 +#: src/modules/rolemenus/cog.py:742 msgctxt "cmd:rolemenu_create|param:message|desc" msgid "Link to an existing message to turn it into a (reaction) role menu" msgstr "" -#: src/modules/rolemenus/cog.py:628 +#: src/modules/rolemenus/cog.py:746 msgctxt "cmd:rolemenu_create|param:menu_style" msgid "Selection style for this menu (using buttons, dropdowns, or reactions)" msgstr "" -#: src/modules/rolemenus/cog.py:632 +#: src/modules/rolemenus/cog.py:750 msgctxt "cmd:rolemenu_create|param:template" msgid "Template to use for the menu message body" msgstr "" -#: src/modules/rolemenus/cog.py:636 +#: src/modules/rolemenus/cog.py:754 msgctxt "cmd:rolemenu_create|param:rawmessage" msgid "Attach a custom menu message to use" msgstr "" -#: src/modules/rolemenus/cog.py:665 +#: src/modules/rolemenus/cog.py:783 msgctxt "cmd:rolemenu_create|error:author_perms" msgid "" "You need the `MANAGE_ROLES` permission in order to create new role menus." msgstr "" -#: src/modules/rolemenus/cog.py:672 +#: src/modules/rolemenus/cog.py:790 msgctxt "cmd:rolemenu_create|error:my_perms" msgid "" "I lack the `MANAGE_ROLES` permission needed to offer roles from role menus." msgstr "" -#: src/modules/rolemenus/cog.py:691 +#: src/modules/rolemenus/cog.py:809 #, possible-python-brace-format msgctxt "cmd:rolemenu_create|error:message_exists" msgid "The message {link} already has a role menu! Use {edit_cmd} to edit it." msgstr "" -#: src/modules/rolemenus/cog.py:712 +#: src/modules/rolemenus/cog.py:830 #, possible-python-brace-format msgctxt "cmd:rolemenu_create|error:style_notmine" msgid "" @@ -222,7 +264,7 @@ msgid "" "restriction)." msgstr "" -#: src/modules/rolemenus/cog.py:719 +#: src/modules/rolemenus/cog.py:837 #, possible-python-brace-format msgctxt "cmd:rolemenu_create|error:rawmessage_notmine" msgid "" @@ -230,7 +272,7 @@ msgid "" "message!" msgstr "" -#: src/modules/rolemenus/cog.py:728 +#: src/modules/rolemenus/cog.py:846 #, possible-python-brace-format msgctxt "cmd:rolemenu_create|error:template_notmine" msgid "" @@ -238,153 +280,153 @@ msgid "" "message!" msgstr "" -#: src/modules/rolemenus/cog.py:741 +#: src/modules/rolemenus/cog.py:859 #, possible-python-brace-format msgctxt "cmd:rolemenu_create|error:name_exists" msgid "A rolemenu called `{name}` already exists! Use {edit_cmd} to edit it." msgstr "" -#: src/modules/rolemenus/cog.py:789 +#: src/modules/rolemenus/cog.py:907 msgctxt "cmd:rolemenu_edit" msgid "editmenu" msgstr "" -#: src/modules/rolemenus/cog.py:792 +#: src/modules/rolemenus/cog.py:910 msgctxt "cmd:rolemenu_edit|desc" msgid "Edit an existing role menu." msgstr "" -#: src/modules/rolemenus/cog.py:801 +#: src/modules/rolemenus/cog.py:919 msgctxt "cmd:rolemenu_edit|param:name" msgid "name" msgstr "" -#: src/modules/rolemenus/cog.py:802 +#: src/modules/rolemenus/cog.py:920 msgctxt "cmd:rolemenu_edit|param:new_name" msgid "new_name" msgstr "" -#: src/modules/rolemenus/cog.py:803 +#: src/modules/rolemenus/cog.py:921 msgctxt "cmd:rolemenu_edit|param:channel" msgid "new_channel" msgstr "" -#: src/modules/rolemenus/cog.py:808 +#: src/modules/rolemenus/cog.py:926 msgctxt "cmd:rolemenu_edit|param:menu_style" msgid "menu_style" msgstr "" -#: src/modules/rolemenus/cog.py:809 +#: src/modules/rolemenus/cog.py:927 msgctxt "cmd:rolemenu_edit|param:remplate" msgid "template" msgstr "" -#: src/modules/rolemenus/cog.py:810 +#: src/modules/rolemenus/cog.py:928 msgctxt "cmd:rolemenu_edit|param:rawmessage" msgid "custom_message" msgstr "" -#: src/modules/rolemenus/cog.py:815 +#: src/modules/rolemenus/cog.py:933 msgctxt "cmd:rolemenu_edit|param:name|desc" msgid "Name of the menu to edit" msgstr "" -#: src/modules/rolemenus/cog.py:819 +#: src/modules/rolemenus/cog.py:937 msgctxt "cmd:rolemenu_edit|param:channel|desc" msgid "Server channel to move the menu to" msgstr "" -#: src/modules/rolemenus/cog.py:828 +#: src/modules/rolemenus/cog.py:946 msgctxt "cmd:rolemenu_edit|param:menu_style" msgid "Selection style for this menu (using buttons, dropdowns, or reactions)" msgstr "" -#: src/modules/rolemenus/cog.py:832 +#: src/modules/rolemenus/cog.py:950 msgctxt "cmd:rolemenu_edit|param:template" msgid "Template to use for the menu message body" msgstr "" -#: src/modules/rolemenus/cog.py:836 +#: src/modules/rolemenus/cog.py:954 msgctxt "cmd:rolemenu_edit|param:rawmessage" msgid "Attach a custom menu message to use" msgstr "" -#: src/modules/rolemenus/cog.py:865 +#: src/modules/rolemenus/cog.py:983 msgctxt "cmd:rolemenu_edit|error:author_perms" msgid "You need the `MANAGE_ROLES` permission in order to edit role menus." msgstr "" -#: src/modules/rolemenus/cog.py:872 +#: src/modules/rolemenus/cog.py:990 msgctxt "cmd:rolemenu_edit|error:my_perms" msgid "" "I lack the `MANAGE_ROLES` permission needed to offer roles from role menus." msgstr "" -#: src/modules/rolemenus/cog.py:895 +#: src/modules/rolemenus/cog.py:1013 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|error:menu_not_found" msgid "This server does not have a role menu called `{name}`!" msgstr "" -#: src/modules/rolemenus/cog.py:916 +#: src/modules/rolemenus/cog.py:1034 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|parse:new_name|error:name_exists" msgid "A role menu with the name **{new_name}** already exists!" msgstr "" -#: src/modules/rolemenus/cog.py:953 +#: src/modules/rolemenus/cog.py:1071 msgctxt "cmd:rolemenu_edit|parse:template|error:not_managed" msgid "" "Cannot set a template message for a role menu attached to a message I did " "not send." msgstr "" -#: src/modules/rolemenus/cog.py:964 +#: src/modules/rolemenus/cog.py:1082 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|parse:template|success:template" msgid "Now using the `{name}` menu message template." msgstr "" -#: src/modules/rolemenus/cog.py:971 +#: src/modules/rolemenus/cog.py:1089 msgctxt "cmd:rolemenu_edit|parse:template|success:custom" msgid "Now using a custom menu message." msgstr "" -#: src/modules/rolemenus/cog.py:981 +#: src/modules/rolemenus/cog.py:1099 msgctxt "cmd:rolemenu_edit|parse:style|error:not_managed" msgid "" "Cannot change the style of a role menu attached to a message I did not send." msgstr "" -#: src/modules/rolemenus/cog.py:991 +#: src/modules/rolemenus/cog.py:1109 msgctxt "cmd:rolemenu_edit|parse:style|error:too_many_reactions" msgid "Too many roles! Reaction role menus can have at most `20` roles." msgstr "" -#: src/modules/rolemenus/cog.py:1001 +#: src/modules/rolemenus/cog.py:1119 msgctxt "cmd:rolemenu_edit|parse:style|error:incomplete_emojis" msgid "" "Cannot switch to the reaction role style! Every role needs a distinct emoji " "first." msgstr "" -#: src/modules/rolemenus/cog.py:1008 +#: src/modules/rolemenus/cog.py:1126 msgctxt "cmd:rolemenu_edit|parse:style|success" msgid "Updated role menu style." msgstr "" -#: src/modules/rolemenus/cog.py:1022 +#: src/modules/rolemenus/cog.py:1140 msgctxt "cmd:rolemenu_edit|parse:custom_message|success" msgid "Custom menu message updated." msgstr "" -#: src/modules/rolemenus/cog.py:1037 +#: src/modules/rolemenus/cog.py:1155 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|repost|success" msgid "The role menu is now available at {message}" msgstr "" -#: src/modules/rolemenus/cog.py:1048 +#: src/modules/rolemenus/cog.py:1166 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|repost|error:forbidden" msgid "" @@ -392,7 +434,7 @@ msgid "" "permission in {channel}." msgstr "" -#: src/modules/rolemenus/cog.py:1053 +#: src/modules/rolemenus/cog.py:1171 #, possible-python-brace-format msgctxt "cmd:rolemenu_edit|repost|error:unknown" msgid "" @@ -400,40 +442,40 @@ msgid "" "**Error:** `{exception}`" msgstr "" -#: src/modules/rolemenus/cog.py:1092 +#: src/modules/rolemenus/cog.py:1210 msgctxt "cmd:rolemenu_delete" msgid "delmenu" msgstr "" -#: src/modules/rolemenus/cog.py:1095 +#: src/modules/rolemenus/cog.py:1213 msgctxt "cmd:rolemenu_delete|desc" msgid "Delete a role menu." msgstr "" -#: src/modules/rolemenus/cog.py:1099 +#: src/modules/rolemenus/cog.py:1217 msgctxt "cmd:rolemenu_delete|param:name" msgid "menu" msgstr "" -#: src/modules/rolemenus/cog.py:1104 +#: src/modules/rolemenus/cog.py:1222 msgctxt "cmd:rolemenu_delete|param:name|desc" msgid "Name of the rolemenu to delete." msgstr "" -#: src/modules/rolemenus/cog.py:1119 +#: src/modules/rolemenus/cog.py:1237 msgctxt "cmd:rolemenu_delete|error:author_perms" msgid "" "You need the `MANAGE_ROLES` permission in order to manage the server role " "menus." msgstr "" -#: src/modules/rolemenus/cog.py:1142 +#: src/modules/rolemenus/cog.py:1260 #, possible-python-brace-format msgctxt "cmd:rolemenu_delete|error:menu_not_found" msgid "This server does not have a role menu called `{name}`!" msgstr "" -#: src/modules/rolemenus/cog.py:1150 +#: src/modules/rolemenus/cog.py:1268 #, possible-python-brace-format msgctxt "cmd:rolemenu_delete|confirm|title" msgid "" @@ -441,212 +483,212 @@ msgid "" "reversible!" msgstr "" -#: src/modules/rolemenus/cog.py:1155 +#: src/modules/rolemenus/cog.py:1273 msgctxt "cmd:rolemenu_delete|confirm|button:yes" msgid "Yes, Delete Now" msgstr "" -#: src/modules/rolemenus/cog.py:1160 +#: src/modules/rolemenus/cog.py:1278 msgctxt "cmd:rolemenu_delete|confirm|button:no" msgid "No, Cancel" msgstr "" -#: src/modules/rolemenus/cog.py:1185 +#: src/modules/rolemenus/cog.py:1303 #, possible-python-brace-format msgctxt "cmd:rolemenu_delete|success|desc" msgid "Successfully deleted the menu **{name}**" msgstr "" -#: src/modules/rolemenus/cog.py:1193 +#: src/modules/rolemenus/cog.py:1311 msgctxt "cmd:rolemenu_addrole" msgid "addrole" msgstr "" -#: src/modules/rolemenus/cog.py:1196 +#: src/modules/rolemenus/cog.py:1314 msgctxt "cmd:rolemenu_addrole|desc" msgid "Add a new role to an existing role menu." msgstr "" -#: src/modules/rolemenus/cog.py:1201 +#: src/modules/rolemenus/cog.py:1319 msgctxt "cmd:rolemenu_addrole|param:menu" msgid "menu" msgstr "" -#: src/modules/rolemenus/cog.py:1204 +#: src/modules/rolemenus/cog.py:1322 msgctxt "cmd:rolemenu_addrole|param:role" msgid "role" msgstr "" -#: src/modules/rolemenus/cog.py:1215 +#: src/modules/rolemenus/cog.py:1333 msgctxt "cmd:rolemenu_addrole|param:menu|desc" msgid "Name of the menu to add a role to" msgstr "" -#: src/modules/rolemenus/cog.py:1219 +#: src/modules/rolemenus/cog.py:1337 msgctxt "cmd:rolemenu_addrole|param:role|desc" msgid "Role to add to the menu" msgstr "" -#: src/modules/rolemenus/cog.py:1227 +#: src/modules/rolemenus/cog.py:1345 msgctxt "cmd:rolemenu_addrole|param:duration|desc" msgid "Lifetime of the role after selection in minutes." msgstr "" -#: src/modules/rolemenus/cog.py:1275 +#: src/modules/rolemenus/cog.py:1393 #, possible-python-brace-format msgctxt "cmd:rolemenu_addrole|error:menu_not_found" msgid "This server does not have a role menu called `{name}`!" msgstr "" -#: src/modules/rolemenus/cog.py:1360 +#: src/modules/rolemenus/cog.py:1478 msgctxt "cmd:rolemenu_addrole|success:create|title" msgid "Added Menu Role" msgstr "" -#: src/modules/rolemenus/cog.py:1364 +#: src/modules/rolemenus/cog.py:1482 #, possible-python-brace-format msgctxt "cmd:rolemenu_addrole|success:create|desc" msgid "Add the role {role} to the menu **{menu}**." msgstr "" -#: src/modules/rolemenus/cog.py:1382 +#: src/modules/rolemenus/cog.py:1500 msgctxt "cmd:rolemenu_addrole|success:edit|title" msgid "Menu Role updated" msgstr "" -#: src/modules/rolemenus/cog.py:1394 +#: src/modules/rolemenus/cog.py:1512 #, possible-python-brace-format msgctxt "cmd:rolemenu_addrole|error:role_exists" msgid "The role {role} is already selectable from the menu **{menu}**" msgstr "" -#: src/modules/rolemenus/cog.py:1412 +#: src/modules/rolemenus/cog.py:1530 msgctxt "cmd:rolemenu_addrole|success|error:reaction|name" msgid "Note" msgstr "" -#: src/modules/rolemenus/cog.py:1424 +#: src/modules/rolemenus/cog.py:1542 msgctxt "cmd:rolemenu_addrole|success|button:editor|label" msgid "Edit Menu" msgstr "" -#: src/modules/rolemenus/cog.py:1441 +#: src/modules/rolemenus/cog.py:1559 msgctxt "cmd:rolemenu_editrole" msgid "editrole" msgstr "" -#: src/modules/rolemenus/cog.py:1444 +#: src/modules/rolemenus/cog.py:1562 msgctxt "cmd:rolemenu_editrole|desc" msgid "Edit role options in an existing role menu." msgstr "" -#: src/modules/rolemenus/cog.py:1449 +#: src/modules/rolemenus/cog.py:1567 msgctxt "cmd:rolemenu_editrole|param:menu" msgid "menu" msgstr "" -#: src/modules/rolemenus/cog.py:1452 +#: src/modules/rolemenus/cog.py:1570 msgctxt "cmd:rolemenu_editrole|param:menu_role" msgid "menu_role" msgstr "" -#: src/modules/rolemenus/cog.py:1455 +#: src/modules/rolemenus/cog.py:1573 msgctxt "cmd:rolemenu_editrole|param:role" msgid "new_role" msgstr "" -#: src/modules/rolemenus/cog.py:1466 +#: src/modules/rolemenus/cog.py:1584 msgctxt "cmd:rolemenu_editrole|param:menu|desc" msgid "Name of the menu to edit the role for" msgstr "" -#: src/modules/rolemenus/cog.py:1470 +#: src/modules/rolemenus/cog.py:1588 msgctxt "cmd:rolemenu_editrole|param:menu_role|desc" msgid "Label, name, or mention of the menu role to edit." msgstr "" -#: src/modules/rolemenus/cog.py:1474 +#: src/modules/rolemenus/cog.py:1592 msgctxt "cmd:rolemenu_editrole|param:role|desc" msgid "New server role this menu role should give." msgstr "" -#: src/modules/rolemenus/cog.py:1482 +#: src/modules/rolemenus/cog.py:1600 msgctxt "cmd:rolemenu_editrole|param:duration|desc" msgid "Lifetime of the role after selection in minutes." msgstr "" -#: src/modules/rolemenus/cog.py:1523 +#: src/modules/rolemenus/cog.py:1641 #, possible-python-brace-format msgctxt "cmd:rolemenu_editrole|error:menu_not_found" msgid "This server does not have a role menu called `{name}`!" msgstr "" -#: src/modules/rolemenus/cog.py:1551 +#: src/modules/rolemenus/cog.py:1669 #, possible-python-brace-format msgctxt "cmd:rolemenu_editrole|error:role_not_found" msgid "The menu **{menu}** does not have the role **{name}**" msgstr "" -#: src/modules/rolemenus/cog.py:1617 +#: src/modules/rolemenus/cog.py:1735 msgctxt "cmd:rolemenu_editrole|success|title" msgid "Role menu role updated" msgstr "" -#: src/modules/rolemenus/cog.py:1632 +#: src/modules/rolemenus/cog.py:1750 msgctxt "cmd:rolemenu_editrole|success|error:reaction|name" msgid "Warning!" msgstr "" -#: src/modules/rolemenus/cog.py:1657 +#: src/modules/rolemenus/cog.py:1775 msgctxt "cmd:rolemenu_delrole" msgid "delrole" msgstr "" -#: src/modules/rolemenus/cog.py:1660 +#: src/modules/rolemenus/cog.py:1778 msgctxt "cmd:rolemenu_delrole|desc" msgid "Remove a role from a role menu." msgstr "" -#: src/modules/rolemenus/cog.py:1664 +#: src/modules/rolemenus/cog.py:1782 msgctxt "cmd:rolemenu_delrole|param:menu" msgid "menu" msgstr "" -#: src/modules/rolemenus/cog.py:1665 +#: src/modules/rolemenus/cog.py:1783 msgctxt "cmd:rolemenu_delrole|param:menu_role" msgid "menu_role" msgstr "" -#: src/modules/rolemenus/cog.py:1670 +#: src/modules/rolemenus/cog.py:1788 msgctxt "cmd:rolemenu_delrole|param:menu|desc" msgid "Name of the menu to delete the role from." msgstr "" -#: src/modules/rolemenus/cog.py:1674 +#: src/modules/rolemenus/cog.py:1792 msgctxt "cmd:rolemenu_delrole|param:menu_role|desc" msgid "Name, label, or mention of the role to delete." msgstr "" -#: src/modules/rolemenus/cog.py:1692 +#: src/modules/rolemenus/cog.py:1810 msgctxt "cmd:rolemenu_delrole|error:author_perms" msgid "" "You need the `MANAGE_ROLES` permission in order to manage the server role " "menus." msgstr "" -#: src/modules/rolemenus/cog.py:1716 +#: src/modules/rolemenus/cog.py:1834 #, possible-python-brace-format msgctxt "cmd:rolemenu_delrole|error:menu_not_found" msgid "This server does not have a role menu called `{name}`!" msgstr "" -#: src/modules/rolemenus/cog.py:1744 +#: src/modules/rolemenus/cog.py:1862 #, possible-python-brace-format msgctxt "cmd:rolemenu_delrole|error:role_not_found" msgid "The menu **{menu}** does not have the role **{name}**" msgstr "" -#: src/modules/rolemenus/cog.py:1761 +#: src/modules/rolemenus/cog.py:1879 #, possible-python-brace-format msgctxt "cmd:rolemenu_delrole|success" msgid "The role **{name}** was successfully removed from the menu **{menu}**." @@ -951,58 +993,80 @@ msgctxt "rolemenu|select|expires_at" msgid "The role will expire at {timestamp}." msgstr "" -#: src/modules/rolemenus/rolemenu.py:627 +#: src/modules/rolemenus/rolemenu.py:616 +msgctxt "rolemenu|eventlog|event:role_equipped|title" +msgid "Member equipped role from role menu" +msgstr "" + +#: src/modules/rolemenus/rolemenu.py:620 +#, possible-python-brace-format +msgctxt "rolemenu|eventlog|event:role_equipped|desc" +msgid "{member} equipped {role} from {menu}" +msgstr "" + +#: src/modules/rolemenus/rolemenu.py:644 #, possible-python-brace-format msgctxt "rolemenu|deselect|error:sticky" msgid "**{role}** is a sticky role, you cannot remove it with this menu!" msgstr "" -#: src/modules/rolemenus/rolemenu.py:638 +#: src/modules/rolemenus/rolemenu.py:655 msgctxt "rolemenu|deselect|error:perms" msgid "I don't have enough permissions to remove this role from you!" msgstr "" -#: src/modules/rolemenus/rolemenu.py:645 +#: src/modules/rolemenus/rolemenu.py:662 msgctxt "rolemenu|deselect|error:discord" msgid "An unknown error occurred removing your role! Please try again later." msgstr "" -#: src/modules/rolemenus/rolemenu.py:674 +#: src/modules/rolemenus/rolemenu.py:691 msgctxt "rolemenu|deslect|success|title" msgid "Role removed" msgstr "" -#: src/modules/rolemenus/rolemenu.py:680 +#: src/modules/rolemenus/rolemenu.py:697 #, possible-python-brace-format msgctxt "rolemenu|deselect|success:refund|desc" msgid "You have removed **{role}**, and been refunded {coin} **{amount}**." msgstr "" -#: src/modules/rolemenus/rolemenu.py:686 +#: src/modules/rolemenus/rolemenu.py:703 #, possible-python-brace-format msgctxt "rolemenu|deselect|success:negrefund|desc" msgid "You have removed **{role}**, and have lost {coin} **{amount}**." msgstr "" -#: src/modules/rolemenus/rolemenu.py:691 +#: src/modules/rolemenus/rolemenu.py:708 #, possible-python-brace-format msgctxt "rolemenu|deselect|success:norefund|desc" msgid "You have unequipped **{role}**." msgstr "" -#: src/modules/rolemenus/rolemenu.py:713 +#: src/modules/rolemenus/rolemenu.py:715 +msgctxt "rolemenu|eventlog|event:role_unequipped|title" +msgid "Member unequipped role from role menu" +msgstr "" + +#: src/modules/rolemenus/rolemenu.py:719 +#, possible-python-brace-format +msgctxt "rolemenu|eventlog|event:role_unequipped|desc" +msgid "{member} unequipped {role} from {menu}" +msgstr "" + +#: src/modules/rolemenus/rolemenu.py:747 #, possible-python-brace-format msgctxt "rolemenu|error:role_gone" msgid "The role **{name}** no longer exists!" msgstr "" -#: src/modules/rolemenus/rolemenu.py:726 +#: src/modules/rolemenus/rolemenu.py:760 #, possible-python-brace-format msgctxt "rolemenu|select|error:required_role" msgid "You need to have the role **{role}** required to use this menu!" msgstr "" -#: src/modules/rolemenus/rolemenu.py:784 +#: src/modules/rolemenus/rolemenu.py:818 #, possible-python-brace-format msgctxt "rolemenu|content:reactions" msgid "[Click here]({jump_link}) to jump back." diff --git a/locales/templates/rooms.pot b/locales/templates/rooms.pot index 61ab9a02..cca6238a 100644 --- a/locales/templates/rooms.pot +++ b/locales/templates/rooms.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -178,83 +178,124 @@ msgctxt "ui:room_status|embed|field:members|name" msgid "Members ({count}/{cap})" msgstr "" -#: src/modules/rooms/cog.py:321 +#: src/modules/rooms/cog.py:175 src/modules/rooms/room.py:364 +msgctxt "room|eventlog|event:room_deleted|title" +msgid "Private Room Deleted" +msgstr "" + +#: src/modules/rooms/cog.py:179 src/modules/rooms/room.py:368 +#, possible-python-brace-format +msgctxt "room|eventlog|event:room_deleted|desc" +msgid "{owner}'s private room was deleted." +msgstr "" + +#: src/modules/rooms/cog.py:270 +#, possible-python-brace-format +msgctxt "create_room|create_channel|audit_reason" +msgid "Creating Private Room for {ownerid}" +msgstr "" + +#: src/modules/rooms/cog.py:279 +msgctxt "eventlog|event:private_room_create_error|name" +msgid "Private Room Creation Failed" +msgstr "" + +#: src/modules/rooms/cog.py:283 +#, possible-python-brace-format +msgctxt "eventlog|event:private_room_create_error|desc" +msgid "" +"{owner} attempted to rent a new private room, but I could not create it!\n" +"They were not charged." +msgstr "" + +#: src/modules/rooms/cog.py:329 +msgctxt "eventlog|event:private_room_create|name" +msgid "Private Room Rented" +msgstr "" + +#: src/modules/rooms/cog.py:333 +#, possible-python-brace-format +msgctxt "eventlog|event:private_room_create|desc" +msgid "{owner} has rented a new private room {channel}!" +msgstr "" + +#: src/modules/rooms/cog.py:368 msgctxt "cmd:room" msgid "room" msgstr "" -#: src/modules/rooms/cog.py:322 +#: src/modules/rooms/cog.py:369 msgctxt "cmd:room|desc" msgid "Base command group for private room configuration." msgstr "" -#: src/modules/rooms/cog.py:329 +#: src/modules/rooms/cog.py:376 msgctxt "cmd:room_rent" msgid "rent" msgstr "" -#: src/modules/rooms/cog.py:332 +#: src/modules/rooms/cog.py:379 msgctxt "cmd:room_rent|desc" msgid "Rent a private voice channel with LionCoins." msgstr "" -#: src/modules/rooms/cog.py:336 +#: src/modules/rooms/cog.py:383 msgctxt "cmd:room_rent|param:days" msgid "days" msgstr "" -#: src/modules/rooms/cog.py:337 +#: src/modules/rooms/cog.py:384 msgctxt "cmd:room_rent|param:members" msgid "members" msgstr "" -#: src/modules/rooms/cog.py:338 +#: src/modules/rooms/cog.py:385 msgctxt "cmd:room_rent|param:name" msgid "name" msgstr "" -#: src/modules/rooms/cog.py:343 +#: src/modules/rooms/cog.py:390 msgctxt "cmd:room_rent|param:days|desc" msgid "Number of days to pre-purchase. (Default: 1)" msgstr "" -#: src/modules/rooms/cog.py:347 +#: src/modules/rooms/cog.py:394 msgctxt "cmd:room_rent|param:members|desc" msgid "Mention the members you want to add to your private room." msgstr "" -#: src/modules/rooms/cog.py:351 +#: src/modules/rooms/cog.py:398 msgctxt "cmd:room_rent|param:name|desc" msgid "Name of your private voice channel." msgstr "" -#: src/modules/rooms/cog.py:369 +#: src/modules/rooms/cog.py:416 msgctxt "cmd:room_rent|error:not_setup" msgid "" "The private room system has not been set up! A private room category needs " "to be set first with `/configure rooms`." msgstr "" -#: src/modules/rooms/cog.py:380 +#: src/modules/rooms/cog.py:427 msgctxt "cmd:room_rent|error:insufficient_perms" msgid "" "I do not have enough permissions to create a new channel under the " "configured private room category!" msgstr "" -#: src/modules/rooms/cog.py:394 +#: src/modules/rooms/cog.py:441 #, possible-python-brace-format msgctxt "cmd:room_rent|error:room_exists" msgid "You already own a private room! Click to visit: {channel}" msgstr "" -#: src/modules/rooms/cog.py:414 +#: src/modules/rooms/cog.py:461 #, possible-python-brace-format msgctxt "cmd:room_rent|error:member_not_found" msgid "Could not find the requested member {mention} in this server!" msgstr "" -#: src/modules/rooms/cog.py:428 +#: src/modules/rooms/cog.py:475 #, possible-python-brace-format msgctxt "cmd:room_rent|error:too_many_members" msgid "" @@ -262,7 +303,7 @@ msgid "" "but the maximum private room size is `{cap}`!" msgstr "" -#: src/modules/rooms/cog.py:442 +#: src/modules/rooms/cog.py:489 #, possible-python-brace-format msgctxt "cmd:room_rent|confirm:purchase" msgid "" @@ -274,18 +315,18 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: src/modules/rooms/cog.py:467 +#: src/modules/rooms/cog.py:514 #, possible-python-brace-format msgctxt "cmd:room_rent|success" msgid "Successfully created your private room {channel}!" msgstr "" -#: src/modules/rooms/cog.py:472 +#: src/modules/rooms/cog.py:519 msgctxt "cmd:room_rent|success|title" msgid "Private Room Created!" msgstr "" -#: src/modules/rooms/cog.py:506 +#: src/modules/rooms/cog.py:553 #, possible-python-brace-format msgctxt "cmd:room_rent|error:insufficient_funds" msgid "" @@ -297,14 +338,14 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: src/modules/rooms/cog.py:540 +#: src/modules/rooms/cog.py:587 msgctxt "cmd:room_rent|error:my_permissions" msgid "" "Could not create your private room! You were not charged.\n" "I have insufficient permissions to create a private room channel." msgstr "" -#: src/modules/rooms/cog.py:552 +#: src/modules/rooms/cog.py:598 #, possible-python-brace-format msgctxt "cmd:room_rent|error:unknown" msgid "" @@ -313,62 +354,62 @@ msgid "" "`{error}`" msgstr "" -#: src/modules/rooms/cog.py:562 +#: src/modules/rooms/cog.py:607 msgctxt "cmd:room_status" msgid "status" msgstr "" -#: src/modules/rooms/cog.py:565 +#: src/modules/rooms/cog.py:610 msgctxt "cmd:room_status|desc" msgid "Display the status of your current room." msgstr "" -#: src/modules/rooms/cog.py:582 +#: src/modules/rooms/cog.py:627 msgctxt "cmd:room_status|error:no_target" msgid "" "Could not identify target private room! Please re-run the command in the " "private room you wish to view the status of." msgstr "" -#: src/modules/rooms/cog.py:597 +#: src/modules/rooms/cog.py:642 msgctxt "cmd:room_invite" msgid "invite" msgstr "" -#: src/modules/rooms/cog.py:600 +#: src/modules/rooms/cog.py:645 msgctxt "cmd:room_invite|desc" msgid "Add members to your private room." msgstr "" -#: src/modules/rooms/cog.py:604 +#: src/modules/rooms/cog.py:649 msgctxt "cmd:room_invite|param:members" msgid "members" msgstr "" -#: src/modules/rooms/cog.py:609 +#: src/modules/rooms/cog.py:654 msgctxt "cmd:room_invite|param:members|desc" msgid "Mention the members you want to add." msgstr "" -#: src/modules/rooms/cog.py:623 +#: src/modules/rooms/cog.py:668 #, possible-python-brace-format msgctxt "cmd:room_invite|error:no_room" msgid "" "You do not own a private room! Use `/room rent` to rent one with {coin}!" msgstr "" -#: src/modules/rooms/cog.py:644 +#: src/modules/rooms/cog.py:689 #, possible-python-brace-format msgctxt "cmd:room_invite|error:member_not_found" msgid "Could not find the invited member {mention} in this server!" msgstr "" -#: src/modules/rooms/cog.py:655 +#: src/modules/rooms/cog.py:700 msgctxt "cmd:room_invite|error:no_new_members" msgid "All members mentioned are already in the room!" msgstr "" -#: src/modules/rooms/cog.py:669 +#: src/modules/rooms/cog.py:714 #, possible-python-brace-format msgctxt "cmd:room_invite|error:too_many_members" msgid "" @@ -376,89 +417,89 @@ msgid "" "you already have `{current}`, and the member cap is `{cap}`!" msgstr "" -#: src/modules/rooms/cog.py:693 +#: src/modules/rooms/cog.py:738 msgctxt "cmd:room_invite|success|ack" msgid "Members Invited successfully." msgstr "" -#: src/modules/rooms/cog.py:701 +#: src/modules/rooms/cog.py:746 msgctxt "cmd:room_kick" msgid "kick" msgstr "" -#: src/modules/rooms/cog.py:704 +#: src/modules/rooms/cog.py:749 msgctxt "cmd:room_kick|desc" msgid "Remove a members from your private room." msgstr "" -#: src/modules/rooms/cog.py:708 +#: src/modules/rooms/cog.py:753 msgctxt "cmd:room_kick|param:members" msgid "members" msgstr "" -#: src/modules/rooms/cog.py:713 +#: src/modules/rooms/cog.py:758 msgctxt "cmd:room_kick|param:members|desc" msgid "" "Mention the members you want to remove. Also accepts space-separated user " "ids." msgstr "" -#: src/modules/rooms/cog.py:727 +#: src/modules/rooms/cog.py:772 #, possible-python-brace-format msgctxt "cmd:room_kick|error:no_room" msgid "" "You do not own a private room! Use `/room rent` to rent one with {coin}!" msgstr "" -#: src/modules/rooms/cog.py:744 +#: src/modules/rooms/cog.py:789 msgctxt "cmd:room_kick|error:no_matching_members" msgid "None of the mentioned members are in this room!" msgstr "" -#: src/modules/rooms/cog.py:761 +#: src/modules/rooms/cog.py:806 msgctxt "cmd:room_kick|success|ack" msgid "Members removed." msgstr "" -#: src/modules/rooms/cog.py:767 +#: src/modules/rooms/cog.py:812 msgctxt "cmd:room_transfer" msgid "transfer" msgstr "" -#: src/modules/rooms/cog.py:770 +#: src/modules/rooms/cog.py:815 msgctxt "cmd:room_transfer|desc" msgid "Transfer your private room to another room member. Not reversible!" msgstr "" -#: src/modules/rooms/cog.py:774 +#: src/modules/rooms/cog.py:819 msgctxt "cmd:room_transfer|param:new_owner" msgid "new_owner" msgstr "" -#: src/modules/rooms/cog.py:779 +#: src/modules/rooms/cog.py:824 msgctxt "cmd:room_transfer|param:new_owner" msgid "The room member you would like to transfer your room to." msgstr "" -#: src/modules/rooms/cog.py:793 +#: src/modules/rooms/cog.py:838 msgctxt "cmd:room_transfer|error:no_room" msgid "You do not own a private room to transfer!" msgstr "" -#: src/modules/rooms/cog.py:805 +#: src/modules/rooms/cog.py:850 #, possible-python-brace-format msgctxt "cmd:room_transfer|error:target_not_member" msgid "" "{mention} is not a member of your private room! You must invite them first." msgstr "" -#: src/modules/rooms/cog.py:817 +#: src/modules/rooms/cog.py:862 #, possible-python-brace-format msgctxt "cmd:room_transfer|error:target_has_room" msgid "{mention} already owns a room! Members can only own one room at a time." msgstr "" -#: src/modules/rooms/cog.py:826 +#: src/modules/rooms/cog.py:871 #, possible-python-brace-format msgctxt "cmd:room_transfer|confirm|question" msgid "" @@ -466,101 +507,164 @@ msgid "" "{new_owner}? This action is not reversible!" msgstr "" -#: src/modules/rooms/cog.py:846 +#: src/modules/rooms/cog.py:891 #, possible-python-brace-format msgctxt "cmd:room_transfer|success|description" msgid "" "You have successfully transferred ownership of {channel} to {new_owner}." msgstr "" -#: src/modules/rooms/cog.py:852 +#: src/modules/rooms/cog.py:897 msgctxt "cmd:room_deposit" msgid "deposit" msgstr "" -#: src/modules/rooms/cog.py:855 +#: src/modules/rooms/cog.py:900 msgctxt "cmd:room_deposit|desc" msgid "" "Deposit LionCoins in your private room bank to add more days. (Members may " "also deposit!)" msgstr "" -#: src/modules/rooms/cog.py:859 +#: src/modules/rooms/cog.py:904 msgctxt "cmd:room_deposit|param:coins" msgid "coins" msgstr "" -#: src/modules/rooms/cog.py:864 +#: src/modules/rooms/cog.py:909 msgctxt "cmd:room_deposit|param:coins|desc" msgid "Number of coins to deposit." msgstr "" -#: src/modules/rooms/cog.py:884 +#: src/modules/rooms/cog.py:929 msgctxt "cmd:room_deposit|error:no_target" msgid "" "Could not identify target private room! Please re-run the command in the " "private room you wish to contribute to." msgstr "" -#: src/modules/rooms/cog.py:900 +#: src/modules/rooms/cog.py:945 #, possible-python-brace-format msgctxt "cmd:room_deposit|error:insufficient_funds" msgid "" "You cannot deposit {coin}**{amount}**! You only have {coin}**{balance}**." msgstr "" -#: src/modules/rooms/cog.py:921 +#: src/modules/rooms/cog.py:966 #, possible-python-brace-format msgctxt "cmd:room_depost|success" msgid "" "Success! You have contributed {coin}**{amount}** to the private room bank." msgstr "" -#: src/modules/rooms/cog.py:936 +#: src/modules/rooms/cog.py:981 msgctxt "cmd:configure_rooms" msgid "rooms" msgstr "" -#: src/modules/rooms/cog.py:937 +#: src/modules/rooms/cog.py:982 msgctxt "cmd:configure_rooms|desc" msgid "Configure Rented Private Rooms" msgstr "" -#: src/modules/rooms/room.py:81 +#: src/modules/rooms/room.py:78 +msgctxt "room|eventlog|field:owner" +msgid "Owner" +msgstr "" + +#: src/modules/rooms/room.py:84 +msgctxt "room|eventlog|field:channel" +msgid "Channel" +msgstr "" + +#: src/modules/rooms/room.py:90 +msgctxt "room|eventlog|field:balance" +msgid "Room Balance" +msgstr "" + +#: src/modules/rooms/room.py:96 +msgctxt "room|eventlog|field:created" +msgid "Created At" +msgstr "" + +#: src/modules/rooms/room.py:102 +msgctxt "room|eventlog|field:tick" +msgid "Next Rent Due" +msgstr "" + +#: src/modules/rooms/room.py:108 +msgctxt "room|eventlog|field:members" +msgid "Private Room Members" +msgstr "" + +#: src/modules/rooms/room.py:123 #, possible-python-brace-format msgctxt "room|notify:deposit|description" msgid "{member} has deposited {coin}**{amount}** into the room bank!" msgstr "" -#: src/modules/rooms/room.py:104 +#: src/modules/rooms/room.py:146 msgctxt "room|notify:new_members|title" msgid "New Members!" msgstr "" -#: src/modules/rooms/room.py:108 +#: src/modules/rooms/room.py:150 #, possible-python-brace-format msgctxt "room|notify:new_members|desc" msgid "Welcome {members}" msgstr "" -#: src/modules/rooms/room.py:183 +#: src/modules/rooms/room.py:156 +msgctxt "room|eventlog|event:new_members|title" +msgid "Members invited to private room" +msgstr "" + +#: src/modules/rooms/room.py:160 +#, possible-python-brace-format +msgctxt "room|eventlog|event:new_members|desc" +msgid "{owner} added members to their private room: {members}" +msgstr "" + +#: src/modules/rooms/room.py:191 +msgctxt "room|eventlog|event:rm_members|title" +msgid "Members removed from private room" +msgstr "" + +#: src/modules/rooms/room.py:195 +#, possible-python-brace-format +msgctxt "room|eventlog|event:rm_members|desc" +msgid "{owner} removed members from their private room: {members}" +msgstr "" + +#: src/modules/rooms/room.py:254 #, possible-python-brace-format msgctxt "room|notify:transfer|description" msgid "{old_owner} has transferred private room ownership to {new_owner}" msgstr "" -#: src/modules/rooms/room.py:247 +#: src/modules/rooms/room.py:318 msgctxt "room|embed:expiry|title" msgid "Private Room Expired!" msgstr "" -#: src/modules/rooms/room.py:251 +#: src/modules/rooms/room.py:322 #, possible-python-brace-format msgctxt "room|embed:expiry|description" msgid "Your private room in **{guild}** has expired!" msgstr "" -#: src/modules/rooms/room.py:265 +#: src/modules/rooms/room.py:332 +msgctxt "room|eventlog|event:expired|title" +msgid "Private Room Expired" +msgstr "" + +#: src/modules/rooms/room.py:336 +#, possible-python-brace-format +msgctxt "room|eventlog|event:expired|desc" +msgid "{owner}'s private room has expired." +msgstr "" + +#: src/modules/rooms/room.py:349 #, possible-python-brace-format msgctxt "room|tick|rent_deducted" msgid "Daily rent deducted from room balance. New balance: {coin}**{amount}**" diff --git a/locales/templates/schedule.pot b/locales/templates/schedule.pot index 1c0ba94f..5cfddbc0 100644 --- a/locales/templates/schedule.pot +++ b/locales/templates/schedule.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/settings_base.pot b/locales/templates/settings_base.pot index 8fce9834..b8e82545 100644 --- a/locales/templates/settings_base.pot +++ b/locales/templates/settings_base.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/shop.pot b/locales/templates/shop.pot index 54526f8a..803856b8 100644 --- a/locales/templates/shop.pot +++ b/locales/templates/shop.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -133,52 +133,63 @@ msgctxt "shop:colour|purchase|error:failed_unknown" msgid "An unknown error occurred while giving you this colour role!" msgstr "" -#: src/modules/shop/shops/colours.py:348 +#: src/modules/shop/shops/colours.py:306 +msgctxt "eventlog|event:purchase_colour|title" +msgid "Member Purchased Colour Role" +msgstr "" + +#: src/modules/shop/shops/colours.py:310 +#, possible-python-brace-format +msgctxt "eventlog|event:purchase_colour|desc" +msgid "{member} purchased {role} from the colour shop." +msgstr "" + +#: src/modules/shop/shops/colours.py:365 msgctxt "grp:editshop_colours" msgid "colours" msgstr "" -#: src/modules/shop/shops/colours.py:353 +#: src/modules/shop/shops/colours.py:370 msgctxt "cmd:editshop_colours_create" msgid "create" msgstr "" -#: src/modules/shop/shops/colours.py:356 +#: src/modules/shop/shops/colours.py:373 msgctxt "cmd:editshop_colours_create|desc" msgid "Create a new colour role with the given colour." msgstr "" -#: src/modules/shop/shops/colours.py:360 +#: src/modules/shop/shops/colours.py:377 msgctxt "cmd:editshop_colours_create|param:colour" msgid "colour" msgstr "" -#: src/modules/shop/shops/colours.py:361 +#: src/modules/shop/shops/colours.py:378 msgctxt "cmd:editshop_colours_create|param:name" msgid "name" msgstr "" -#: src/modules/shop/shops/colours.py:362 +#: src/modules/shop/shops/colours.py:379 msgctxt "cmd:editshop_colours_create|param:price" msgid "price" msgstr "" -#: src/modules/shop/shops/colours.py:367 +#: src/modules/shop/shops/colours.py:384 msgctxt "cmd:editshop_colours_create|param:colour|desc" msgid "What colour should the role be? (As a hex code, e.g. #AB22AB)" msgstr "" -#: src/modules/shop/shops/colours.py:371 +#: src/modules/shop/shops/colours.py:388 msgctxt "cmd:editshop_colours_create|param:name|desc" msgid "What should the colour role be called?" msgstr "" -#: src/modules/shop/shops/colours.py:375 +#: src/modules/shop/shops/colours.py:392 msgctxt "cmd:editshop_colours_create|param:price|desc" msgid "How much should the colour role cost?" msgstr "" -#: src/modules/shop/shops/colours.py:397 +#: src/modules/shop/shops/colours.py:414 #, possible-python-brace-format msgctxt "cmd:editshop_colours_create|error:parse_colour" msgid "" @@ -186,7 +197,7 @@ msgid "" "Please enter the colour as a hex string, e.g. `#FA0BC1`" msgstr "" -#: src/modules/shop/shops/colours.py:407 +#: src/modules/shop/shops/colours.py:424 msgctxt "cmd:editshop_colours_create|error:perms" msgid "" "I do not have permission to create server roles!\n" @@ -194,26 +205,26 @@ msgid "" "editshop colours add` instead." msgstr "" -#: src/modules/shop/shops/colours.py:424 +#: src/modules/shop/shops/colours.py:441 msgctxt "cmd:editshop_colours_create|error:max_colours" msgid "" "This server already has the maximum of `25` colour roles!\n" "Please remove some before adding or creating more." msgstr "" -#: src/modules/shop/shops/colours.py:443 +#: src/modules/shop/shops/colours.py:460 msgctxt "cmd:editshop_colours_create|error:failed_unknown" msgid "" "An unknown Discord error occurred while creating your colour role!\n" "Please try again in a few minutes." msgstr "" -#: src/modules/shop/shops/colours.py:493 +#: src/modules/shop/shops/colours.py:515 msgctxt "cmd:editshop_colours_create|resp:done|title" msgid "Colour Role Created" msgstr "" -#: src/modules/shop/shops/colours.py:497 +#: src/modules/shop/shops/colours.py:519 #, possible-python-brace-format msgctxt "cmd:editshop_colours_create|resp:done|desc" msgid "" @@ -221,80 +232,80 @@ msgid "" "{coin}**{price}**!" msgstr "" -#: src/modules/shop/shops/colours.py:504 +#: src/modules/shop/shops/colours.py:526 msgctxt "cmd:editshop_colours_create|resp:done|field:position_note|value" msgid "" "The new colour role was added below all other roles. Remember a member's " "active colour is determined by their highest coloured role!" msgstr "" -#: src/modules/shop/shops/colours.py:508 +#: src/modules/shop/shops/colours.py:530 msgctxt "cmd:editshop_colours_create|resp:done|field:position_note|name" msgid "Note" msgstr "" -#: src/modules/shop/shops/colours.py:517 +#: src/modules/shop/shops/colours.py:539 msgctxt "cmd:editshop_colours_edit" msgid "edit" msgstr "" -#: src/modules/shop/shops/colours.py:520 +#: src/modules/shop/shops/colours.py:542 msgctxt "cmd:editshop_colours_edit|desc" msgid "Edit the name, colour, or price of a colour role." msgstr "" -#: src/modules/shop/shops/colours.py:524 +#: src/modules/shop/shops/colours.py:546 msgctxt "cmd:editshop_colours_edit|param:role" msgid "role" msgstr "" -#: src/modules/shop/shops/colours.py:525 +#: src/modules/shop/shops/colours.py:547 msgctxt "cmd:editshop_colours_edit|param:name" msgid "name" msgstr "" -#: src/modules/shop/shops/colours.py:526 +#: src/modules/shop/shops/colours.py:548 msgctxt "cmd:editshop_colours_edit|param:colour" msgid "colour" msgstr "" -#: src/modules/shop/shops/colours.py:527 +#: src/modules/shop/shops/colours.py:549 msgctxt "cmd:editshop_colours_edit|param:price" msgid "price" msgstr "" -#: src/modules/shop/shops/colours.py:532 +#: src/modules/shop/shops/colours.py:554 msgctxt "cmd:editshop_colours_edit|param:role|desc" msgid "Select a colour role to edit." msgstr "" -#: src/modules/shop/shops/colours.py:536 +#: src/modules/shop/shops/colours.py:558 msgctxt "cmd:editshop_colours_edit|param:name|desc" msgid "New name to give the colour role." msgstr "" -#: src/modules/shop/shops/colours.py:540 +#: src/modules/shop/shops/colours.py:562 msgctxt "cmd:editshop_colours_edit|param:colour|desc" msgid "New colour for the colour role (as hex, e.g. #AB12AB)." msgstr "" -#: src/modules/shop/shops/colours.py:544 +#: src/modules/shop/shops/colours.py:566 msgctxt "cmd:editshop_colours_edit|param:price|desc" msgid "New price for the colour role." msgstr "" -#: src/modules/shop/shops/colours.py:573 +#: src/modules/shop/shops/colours.py:595 #, possible-python-brace-format msgctxt "cmd:editshop_colours_edit|error:invalid_role" msgid "{mention} is not in the colour role shop!" msgstr "" -#: src/modules/shop/shops/colours.py:587 +#: src/modules/shop/shops/colours.py:609 msgctxt "cmd:editshop_colours_edit|error:no_args" msgid "You must give me one of `name`, `colour`, or `price` to update!" msgstr "" -#: src/modules/shop/shops/colours.py:603 +#: src/modules/shop/shops/colours.py:625 #, possible-python-brace-format msgctxt "cmd:editshop_colours_edit|error:parse_colour" msgid "" @@ -302,82 +313,82 @@ msgid "" "Please enter the colour as a hex string, e.g. `#FA0BC1`" msgstr "" -#: src/modules/shop/shops/colours.py:618 +#: src/modules/shop/shops/colours.py:640 msgctxt "cmd:editshop_colours_edit|error:perms" msgid "I do not have sufficient server permissions to edit this role!" msgstr "" -#: src/modules/shop/shops/colours.py:634 +#: src/modules/shop/shops/colours.py:656 #, possible-python-brace-format msgctxt "cmd:editshop_colours_edit|resp:done|line:price" msgid "{tick} Set price to {coin}**{price}**" msgstr "" -#: src/modules/shop/shops/colours.py:651 +#: src/modules/shop/shops/colours.py:673 #, possible-python-brace-format msgctxt "cmd:editshop_colours_edit|resp:done|line:role" msgid "{tick} Updated role to {mention}" msgstr "" -#: src/modules/shop/shops/colours.py:661 +#: src/modules/shop/shops/colours.py:683 msgctxt "cmd:editshop_colours_edit|resp:done|embed:title" msgid "Colour Role Updated" msgstr "" -#: src/modules/shop/shops/colours.py:667 +#: src/modules/shop/shops/colours.py:689 msgctxt "cmd:editshop_colours_auto" msgid "auto" msgstr "" -#: src/modules/shop/shops/colours.py:668 +#: src/modules/shop/shops/colours.py:690 msgctxt "cmd:editshop_colours_auto|desc" msgid "Automatically create a set of colour roles." msgstr "" -#: src/modules/shop/shops/colours.py:677 +#: src/modules/shop/shops/colours.py:699 msgctxt "cmd:editshop_colours_add" msgid "add" msgstr "" -#: src/modules/shop/shops/colours.py:680 +#: src/modules/shop/shops/colours.py:702 msgctxt "cmd:editshop_colours_add|desc" msgid "Add an existing role to the colour shop." msgstr "" -#: src/modules/shop/shops/colours.py:684 +#: src/modules/shop/shops/colours.py:706 msgctxt "cmd:editshop_colours_add|param:role" msgid "role" msgstr "" -#: src/modules/shop/shops/colours.py:685 +#: src/modules/shop/shops/colours.py:707 msgctxt "cmd:editshop_colours_add|param:price" msgid "price" msgstr "" -#: src/modules/shop/shops/colours.py:690 +#: src/modules/shop/shops/colours.py:712 msgctxt "cmd:editshop_colours_add|param:role|desc" msgid "Select a role to add to the colour shop." msgstr "" -#: src/modules/shop/shops/colours.py:694 +#: src/modules/shop/shops/colours.py:716 msgctxt "cmd:editshop_colours_add|param:price|desc" msgid "How much should this role cost?" msgstr "" -#: src/modules/shop/shops/colours.py:719 +#: src/modules/shop/shops/colours.py:741 msgctxt "cmd:editshop_colours_add|error:max_colours" msgid "" "This server already has the maximum of `25` colour roles!\n" "Please remove some before adding or creating more." msgstr "" -#: src/modules/shop/shops/colours.py:728 +#: src/modules/shop/shops/colours.py:750 #, possible-python-brace-format msgctxt "cmd:editshop_colours_add|error:role_exists" msgid "The role {mention} is already registered as a colour role!" msgstr "" -#: src/modules/shop/shops/colours.py:737 +#: src/modules/shop/shops/colours.py:759 #, possible-python-brace-format msgctxt "cmd:editshop_colours_add|error:role_perms" msgid "" @@ -386,7 +397,7 @@ msgid "" "role." msgstr "" -#: src/modules/shop/shops/colours.py:748 +#: src/modules/shop/shops/colours.py:770 #, possible-python-brace-format msgctxt "cmd:editshop_colours_add|error:caller_perms" msgid "" @@ -395,138 +406,138 @@ msgid "" "role." msgstr "" -#: src/modules/shop/shops/colours.py:760 +#: src/modules/shop/shops/colours.py:782 msgctxt "cmd:editshop_colours_add|error:role_has_admin" msgid "" "I refuse to add an administrator role to the LionCoin shop. That is a really " "bad idea." msgstr "" -#: src/modules/shop/shops/colours.py:781 +#: src/modules/shop/shops/colours.py:803 msgctxt "cmd:editshop_colours_add|resp:done|embed:title" msgid "Colour Role Created" msgstr "" -#: src/modules/shop/shops/colours.py:784 +#: src/modules/shop/shops/colours.py:806 #, possible-python-brace-format msgctxt "cmd:editshop_colours_add|resp:done|embed:desc" msgid "You have added {mention} to the colour shop for {coin}**{price}**!" msgstr "" -#: src/modules/shop/shops/colours.py:792 +#: src/modules/shop/shops/colours.py:814 msgctxt "cmd:editshop_colours_clear" msgid "clear" msgstr "" -#: src/modules/shop/shops/colours.py:795 +#: src/modules/shop/shops/colours.py:817 msgctxt "cmd:editshop_colours_clear|desc" msgid "" "Remove all the colour roles from the shop, and optionally delete the roles." msgstr "" -#: src/modules/shop/shops/colours.py:799 +#: src/modules/shop/shops/colours.py:821 msgctxt "cmd:editshop_colours_clear|param:delete" msgid "delete_roles" msgstr "" -#: src/modules/shop/shops/colours.py:804 +#: src/modules/shop/shops/colours.py:826 msgctxt "cmd:editshop_colours_clear|param:delete|desc" msgid "Also delete the associated roles." msgstr "" -#: src/modules/shop/shops/colours.py:846 +#: src/modules/shop/shops/colours.py:868 msgctxt "cmd:editshop_colours_clear|error:no_colours" msgid "There are no coloured roles to remove!" msgstr "" -#: src/modules/shop/shops/colours.py:890 +#: src/modules/shop/shops/colours.py:912 #, possible-python-brace-format msgctxt "cmd:editshop_colours_clear|resp:done|line:clear" msgid "{tick} Colour shop cleared." msgstr "" -#: src/modules/shop/shops/colours.py:901 +#: src/modules/shop/shops/colours.py:923 #, possible-python-brace-format msgctxt "cmd:editshop_colours_clear|resp:done|line:refunding" msgid "{loading} Refunded **{count}/{total}** members." msgstr "" -#: src/modules/shop/shops/colours.py:907 +#: src/modules/shop/shops/colours.py:929 #, possible-python-brace-format msgctxt "cmd:editshop_colours_clear|resp:done|line:refunded" msgid "{tick} Refunded **{total}/{total}** members." msgstr "" -#: src/modules/shop/shops/colours.py:920 +#: src/modules/shop/shops/colours.py:942 #, possible-python-brace-format msgctxt "cmd:editshop_colours_clear|resp:done|line:deleted_failed" msgid "" "{emoji} Deleted **{count}/{total}** colour roles. (**{failed}** failed!)" msgstr "" -#: src/modules/shop/shops/colours.py:925 +#: src/modules/shop/shops/colours.py:947 #, possible-python-brace-format msgctxt "cmd:editshop_colours_clear|resp:done|line:deleted" msgid "{emoji} Deleted **{count}/{total}** colour roles." msgstr "" -#: src/modules/shop/shops/colours.py:974 +#: src/modules/shop/shops/colours.py:996 msgctxt "cmd:editshop_colours_remove" msgid "remove" msgstr "" -#: src/modules/shop/shops/colours.py:977 +#: src/modules/shop/shops/colours.py:999 msgctxt "cmd:editshop_colours_remove|desc" msgid "Remove a specific colour role from the shop." msgstr "" -#: src/modules/shop/shops/colours.py:981 +#: src/modules/shop/shops/colours.py:1003 msgctxt "cmd:editshop_colours_remove|param:role" msgid "role" msgstr "" -#: src/modules/shop/shops/colours.py:982 +#: src/modules/shop/shops/colours.py:1004 msgctxt "cmd:editshop_colours_remove" msgid "delete_role" msgstr "" -#: src/modules/shop/shops/colours.py:987 +#: src/modules/shop/shops/colours.py:1009 msgctxt "cmd:editshop_colours_remove|param:role|desc" msgid "Select the colour role to remove." msgstr "" -#: src/modules/shop/shops/colours.py:991 +#: src/modules/shop/shops/colours.py:1013 msgctxt "cmd:editshop_colours_remove|param:delete_role|desc" msgid "Whether to delete the associated role." msgstr "" -#: src/modules/shop/shops/colours.py:1016 +#: src/modules/shop/shops/colours.py:1038 #, possible-python-brace-format msgctxt "cmd:editshop_colours_remove|error:not_colour" msgid "{mention} is not in the colour role shop!" msgstr "" -#: src/modules/shop/shops/colours.py:1034 +#: src/modules/shop/shops/colours.py:1056 msgctxt "cmd:editshop_colours_remove|resp:done|line:delete" msgid "Successfully deleted the role." msgstr "" -#: src/modules/shop/shops/colours.py:1039 +#: src/modules/shop/shops/colours.py:1061 msgctxt "cmd:editshop_colours_remove|resp:done|line:delete" msgid "I do not have sufficient permissions to delete the role." msgstr "" -#: src/modules/shop/shops/colours.py:1044 +#: src/modules/shop/shops/colours.py:1066 msgctxt "cmd:editshop_colours_remove|resp:done|line:delete" msgid "Failed to delete the role for an unknown reason." msgstr "" -#: src/modules/shop/shops/colours.py:1049 +#: src/modules/shop/shops/colours.py:1071 msgctxt "cmd:editshop_colours_remove|resp:done|line:delete" msgid "Could not find the role in order to delete it." msgstr "" -#: src/modules/shop/shops/colours.py:1060 +#: src/modules/shop/shops/colours.py:1082 #, possible-python-brace-format msgctxt "cmd:editshop_colours_remove|resp:done|embed:desc" msgid "" @@ -534,61 +545,61 @@ msgid "" "{delete_line}" msgstr "" -#: src/modules/shop/shops/colours.py:1138 +#: src/modules/shop/shops/colours.py:1160 msgctxt "ui:colourstore|menu:buycolours|embed:error|title" msgid "Purchase Failed!" msgstr "" -#: src/modules/shop/shops/colours.py:1149 +#: src/modules/shop/shops/colours.py:1171 #, possible-python-brace-format msgctxt "ui:colourstore|menu:buycolours|resp:done|desc" msgid "{tick} You have purchased {mention}" msgstr "" -#: src/modules/shop/shops/colours.py:1184 +#: src/modules/shop/shops/colours.py:1206 msgctxt "ui:colourstore|menu:buycolours|placeholder" msgid "There are no colour roles available to purchase!" msgstr "" -#: src/modules/shop/shops/colours.py:1190 +#: src/modules/shop/shops/colours.py:1212 msgctxt "ui:colourstore|menu:buycolours|placeholder" msgid "Select a colour role to purchase!" msgstr "" -#: src/modules/shop/shops/colours.py:1236 +#: src/modules/shop/shops/colours.py:1258 #, possible-python-brace-format msgctxt "ui:colourstore|embed|line:owned_item" msgid "`[{j:02}]` | `{price} LC` | {mention} (You own this!)" msgstr "" -#: src/modules/shop/shops/colours.py:1241 +#: src/modules/shop/shops/colours.py:1263 #, possible-python-brace-format msgctxt "ui:colourstore|embed|line:item" msgid "`[{j:02}]` | `{price} LC` | {mention}" msgstr "" -#: src/modules/shop/shops/colours.py:1248 +#: src/modules/shop/shops/colours.py:1270 msgctxt "ui:colourstore|embed|desc" msgid "No colour roles available for purchase!" msgstr "" -#: src/modules/shop/shops/colours.py:1251 +#: src/modules/shop/shops/colours.py:1273 msgctxt "ui:colourstore|embed|title" msgid "Colour Role Shop" msgstr "" -#: src/modules/shop/shops/colours.py:1257 +#: src/modules/shop/shops/colours.py:1279 #, possible-python-brace-format msgctxt "ui:colourstore|embed|footer:paged" msgid "Page {current}/{total}" msgstr "" -#: src/modules/shop/shops/colours.py:1264 +#: src/modules/shop/shops/colours.py:1286 msgctxt "ui:colourstore|embed|field:warning|name" msgid "Note!" msgstr "" -#: src/modules/shop/shops/colours.py:1268 +#: src/modules/shop/shops/colours.py:1290 #, possible-python-brace-format msgctxt "ui:colourstore|embed|field:warning|value" msgid "" diff --git a/locales/templates/statistics.pot b/locales/templates/statistics.pot index e68844bc..decec2a1 100644 --- a/locales/templates/statistics.pot +++ b/locales/templates/statistics.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,58 +17,93 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/modules/statistics/cog.py:43 +#: src/modules/statistics/cog.py:47 msgctxt "cmd:me" msgid "me" msgstr "" -#: src/modules/statistics/cog.py:46 +#: src/modules/statistics/cog.py:50 msgctxt "cmd:me|desc" -msgid "Display your personal profile and summary statistics." +msgid "Edit your personal profile and see your statistics." msgstr "" -#: src/modules/statistics/cog.py:57 +#: src/modules/statistics/cog.py:61 +msgctxt "cmd:profile" +msgid "profile" +msgstr "" + +#: src/modules/statistics/cog.py:64 +msgctxt "cmd:profile|desc" +msgid "Display the target's profile and statistics summary." +msgstr "" + +#: src/modules/statistics/cog.py:68 +msgctxt "cmd:profile|param:member" +msgid "member" +msgstr "" + +#: src/modules/statistics/cog.py:72 +msgctxt "cmd:profile|param:member|desc" +msgid "Member to display profile for." +msgstr "" + +#: src/modules/statistics/cog.py:105 msgctxt "cmd:stats" msgid "stats" msgstr "" -#: src/modules/statistics/cog.py:60 +#: src/modules/statistics/cog.py:108 msgctxt "cmd:stats|desc" msgid "Weekly and monthly statistics for your recent activity." msgstr "" -#: src/modules/statistics/cog.py:74 +#: src/modules/statistics/cog.py:122 msgctxt "cmd:leaderboard" msgid "leaderboard" msgstr "" -#: src/modules/statistics/cog.py:77 +#: src/modules/statistics/cog.py:125 msgctxt "cmd:leaderboard|desc" msgid "Server leaderboard." msgstr "" -#: src/modules/statistics/cog.py:92 +#: src/modules/statistics/cog.py:140 #, possible-python-brace-format msgctxt "cmd:leaderboard|chunking|desc" msgid "Requesting server member list from Discord, please wait {loading}" msgstr "" -#: src/modules/statistics/cog.py:115 +#: src/modules/statistics/cog.py:157 +msgctxt "cmd:achievements" +msgid "achievements" +msgstr "" + +#: src/modules/statistics/cog.py:160 +msgctxt "cmd:achievements|desc" +msgid "View your progress towards the activity achievement awards!" +msgstr "" + +#: src/modules/statistics/cog.py:177 +msgctxt "cmd:achievements|embed:title" +msgid "Achievements" +msgstr "" + +#: src/modules/statistics/cog.py:195 msgctxt "cmd:configure_statistics" msgid "statistics" msgstr "" -#: src/modules/statistics/cog.py:116 +#: src/modules/statistics/cog.py:196 msgctxt "cmd:configure_statistics|desc" msgid "Statistics configuration panel" msgstr "" -#: src/modules/statistics/cog.py:119 +#: src/modules/statistics/cog.py:199 msgctxt "cmd:configure_statistics|param:season_start" msgid "season_start" msgstr "" -#: src/modules/statistics/cog.py:124 +#: src/modules/statistics/cog.py:204 msgctxt "cmd:configure_statistics|param:season_start|desc" msgid "" "Time from which to start counting activity for rank badges and season " @@ -330,6 +365,12 @@ msgctxt "dash:stats|dropdown|placeholder" msgid "Activity Statistics Panel" msgstr "" +#: src/modules/statistics/graphics/stats.py:63 +#, possible-python-brace-format +msgctxt "gui:stats|mode:voice|month" +msgid "{hours} hours" +msgstr "" + #: src/modules/statistics/ui/goals.py:92 msgctxt "ui:MonthlyUI|name" msgid "Monthly" @@ -613,108 +654,108 @@ msgid "" "again to revert." msgstr "" -#: src/modules/statistics/ui/leaderboard.py:253 +#: src/modules/statistics/ui/leaderboard.py:257 msgctxt "ui:leaderboard|menu:stats|placeholder" msgid "Select Activity Type" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:262 +#: src/modules/statistics/ui/leaderboard.py:266 msgctxt "ui:leaderboard|menu:stats|item:voice" msgid "Voice Activity" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:273 +#: src/modules/statistics/ui/leaderboard.py:277 msgctxt "ui:leaderboard|menu:stats|item:study" msgid "Study Statistics" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:284 +#: src/modules/statistics/ui/leaderboard.py:288 msgctxt "ui:leaderboard|menu:stats|item:message" msgid "Message Activity" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:295 +#: src/modules/statistics/ui/leaderboard.py:299 msgctxt "ui:leaderboard|menu;stats|item:anki" msgid "Anki Cards Reviewed" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:349 +#: src/modules/statistics/ui/leaderboard.py:353 msgctxt "ui:leaderboard|button:season|label" msgid "This Season" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:353 +#: src/modules/statistics/ui/leaderboard.py:357 msgctxt "ui:leaderboard|button:day|label" msgid "Today" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:357 +#: src/modules/statistics/ui/leaderboard.py:361 msgctxt "ui:leaderboard|button:week|label" msgid "This Week" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:361 +#: src/modules/statistics/ui/leaderboard.py:365 msgctxt "ui:leaderboard|button:month|label" msgid "This Month" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:365 +#: src/modules/statistics/ui/leaderboard.py:369 msgctxt "ui:leaderboard|button:alltime|label" msgid "All Time" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:369 +#: src/modules/statistics/ui/leaderboard.py:373 msgctxt "ui:leaderboard|button:jump|label" msgid "Jump" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:384 +#: src/modules/statistics/ui/leaderboard.py:388 msgctxt "ui:leaderboard|button:jump|input:title" msgid "Jump to page" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:388 +#: src/modules/statistics/ui/leaderboard.py:392 msgctxt "ui:leaderboard|button:jump|input:question" msgid "Page number to jump to" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:399 +#: src/modules/statistics/ui/leaderboard.py:403 msgctxt "ui:leaderboard|button:jump|error:invalid_page" msgid "Invalid page number, please try again!" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:443 +#: src/modules/statistics/ui/leaderboard.py:447 msgctxt "ui:leaderboard|chunk_warning" msgid "" "**Note:** Could not retrieve member list from Discord, so some members may " "be missing. Try again in a minute!" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:450 +#: src/modules/statistics/ui/leaderboard.py:454 #, possible-python-brace-format msgctxt "ui:leaderboard|since" msgid "Counting statistics since {timestamp}" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:463 +#: src/modules/statistics/ui/leaderboard.py:467 #, possible-python-brace-format msgctxt "ui:leaderboard|mode:voice|message:empty|desc" msgid "There has been no voice activity since {timestamp}" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:468 +#: src/modules/statistics/ui/leaderboard.py:472 #, possible-python-brace-format msgctxt "ui:leaderboard|mode:text|message:empty|desc" msgid "There has been no message activity since {timestamp}" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:473 +#: src/modules/statistics/ui/leaderboard.py:477 #, possible-python-brace-format msgctxt "ui:leaderboard|mode:anki|message:empty|desc" msgid "There have been no Anki cards reviewed since {timestamp}" msgstr "" -#: src/modules/statistics/ui/leaderboard.py:482 +#: src/modules/statistics/ui/leaderboard.py:486 msgctxt "ui:leaderboard|message:empty|title" msgid "Leaderboard Empty!" msgstr "" diff --git a/locales/templates/stats-gui.pot b/locales/templates/stats-gui.pot index 0e659e79..6863bb66 100644 --- a/locales/templates/stats-gui.pot +++ b/locales/templates/stats-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,121 +18,121 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" #. First column -#: src/gui/cards/stats.py:72 +#: src/gui/cards/stats.py:61 msgctxt "skin:stats|header:col1" msgid "STATISTICS" msgstr "" -#: src/gui/cards/stats.py:75 +#: src/gui/cards/stats.py:64 msgctxt "skin:stats|subheader:leaderboard" msgid "LEADERBOARD POSITION" msgstr "" -#: src/gui/cards/stats.py:79 +#: src/gui/cards/stats.py:68 msgctxt "skin:stats|mode:study|subheader:study" msgid "STUDY TIME" msgstr "" -#: src/gui/cards/stats.py:83 +#: src/gui/cards/stats.py:72 msgctxt "skin:stats|mode:voice|subheader:study" msgid "VOICE TIME" msgstr "" -#: src/gui/cards/stats.py:87 +#: src/gui/cards/stats.py:76 msgctxt "skin:stats|mode:text|subheader:study" msgid "MESSAGES" msgstr "" -#: src/gui/cards/stats.py:91 +#: src/gui/cards/stats.py:80 msgctxt "skin:stats|mode:anki|subheader:study" msgid "CARDS REVIEWED" msgstr "" -#: src/gui/cards/stats.py:102 +#: src/gui/cards/stats.py:91 msgctxt "skin:stats|subheader:workouts" msgid "WORKOUTS" msgstr "" -#: src/gui/cards/stats.py:118 +#: src/gui/cards/stats.py:107 msgctxt "skin:stats|field:daily" msgid "DAILY" msgstr "" -#: src/gui/cards/stats.py:122 +#: src/gui/cards/stats.py:111 msgctxt "skin:stats|field:weekly" msgid "WEEKLY" msgstr "" -#: src/gui/cards/stats.py:126 +#: src/gui/cards/stats.py:115 msgctxt "skin:stats|field:monthly" msgid "MONTHLY" msgstr "" -#: src/gui/cards/stats.py:130 +#: src/gui/cards/stats.py:119 msgctxt "skin:stats|field:alltime" msgid "ALL TIME" msgstr "" -#: src/gui/cards/stats.py:134 +#: src/gui/cards/stats.py:123 msgctxt "skin:stats|field:time" msgid "TIME" msgstr "" -#: src/gui/cards/stats.py:138 +#: src/gui/cards/stats.py:127 msgctxt "skin:stats|field:anki" msgid "ANKI: COMING SOON" msgstr "" -#: src/gui/cards/stats.py:155 +#: src/gui/cards/stats.py:144 msgctxt "skin:stats|mode:study|header:col2" msgid "STUDY STREAK" msgstr "" -#: src/gui/cards/stats.py:159 +#: src/gui/cards/stats.py:148 msgctxt "skin:stats|mode:voice|header:col2" msgid "VOICE STREAK" msgstr "" -#: src/gui/cards/stats.py:163 +#: src/gui/cards/stats.py:152 msgctxt "skin:stats|mode:text|header:col2" msgid "ACTIVITY STREAK" msgstr "" -#: src/gui/cards/stats.py:167 +#: src/gui/cards/stats.py:156 msgctxt "skin:stats|mode:anki|header:col2" msgid "ANKI REVIEW STREAK" msgstr "" -#: src/gui/cards/stats.py:180 +#: src/gui/cards/stats.py:169 #, possible-python-brace-format msgctxt "skin:stats|mode:study|field:col2_summary" msgid "{amount} HRS" msgstr "" -#: src/gui/cards/stats.py:184 +#: src/gui/cards/stats.py:173 #, possible-python-brace-format msgctxt "skin:stats|mode:voice|field:col2_summary" msgid "{amount} HRS" msgstr "" -#: src/gui/cards/stats.py:188 +#: src/gui/cards/stats.py:177 #, possible-python-brace-format msgctxt "skin:stats|mode:text|field:col2_summary" msgid "{amount} XP" msgstr "" -#: src/gui/cards/stats.py:192 +#: src/gui/cards/stats.py:181 #, possible-python-brace-format msgctxt "skin:stats|mode:anki|field:col2_summary" msgid "{amount} CARDS" msgstr "" -#: src/gui/cards/stats.py:223 +#: src/gui/cards/stats.py:212 msgctxt "skin:stats|cal|weekdays" msgid "S,M,T,W,T,F,S" msgstr "" -#: src/gui/cards/stats.py:227 +#: src/gui/cards/stats.py:216 msgctxt "skin:stats|cal|months" msgid "" "JANUARY,FEBRUARY,MARCH,APRIL,MAY,JUNE,JULY,AUGUST,SEPTEMBER,OCTOBER,NOVEMBER," diff --git a/locales/templates/sysadmin.pot b/locales/templates/sysadmin.pot index 05e2d22b..a6549ec8 100644 --- a/locales/templates/sysadmin.pot +++ b/locales/templates/sysadmin.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/tasklist.pot b/locales/templates/tasklist.pot index 7ccbd255..18e2a4a7 100644 --- a/locales/templates/tasklist.pot +++ b/locales/templates/tasklist.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/test.pot b/locales/templates/test.pot index 2ccd8f96..57b9a825 100644 --- a/locales/templates/test.pot +++ b/locales/templates/test.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,66 +17,66 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/modules/test/test.py:59 src/modules/test/test.py:66 +#: src/modules/test/test.py:62 src/modules/test/test.py:145 msgid "test" msgstr "" -#: src/modules/test/test.py:67 +#: src/modules/test/test.py:146 msgid "Test" msgstr "" -#: src/modules/test/test.py:74 +#: src/modules/test/test.py:153 msgid "editor" msgstr "" -#: src/modules/test/test.py:75 +#: src/modules/test/test.py:154 msgid "Test message editor" msgstr "" -#: src/modules/test/test.py:101 +#: src/modules/test/test.py:180 msgid "test_ephemeral" msgstr "" -#: src/modules/test/test.py:102 +#: src/modules/test/test.py:181 msgid "Test ephemeral delete and edit" msgstr "" -#: src/modules/test/test.py:114 +#: src/modules/test/test.py:193 msgid "colours" msgstr "" -#: src/modules/test/test.py:115 +#: src/modules/test/test.py:194 msgid "Test Ansi colours" msgstr "" -#: src/modules/test/test.py:135 +#: src/modules/test/test.py:214 msgid "fail" msgstr "" -#: src/modules/test/test.py:143 +#: src/modules/test/test.py:222 msgid "failui" msgstr "" -#: src/modules/test/test.py:150 +#: src/modules/test/test.py:229 msgid "pager" msgstr "" -#: src/modules/test/test.py:178 +#: src/modules/test/test.py:257 msgid "pager2" msgstr "" -#: src/modules/test/test.py:209 +#: src/modules/test/test.py:288 msgid "prettyusers" msgstr "" -#: src/modules/test/test.py:259 +#: src/modules/test/test.py:338 msgid "dmview" msgstr "" -#: src/modules/test/test.py:270 +#: src/modules/test/test.py:349 msgid "multiview" msgstr "" -#: src/modules/test/test.py:287 +#: src/modules/test/test.py:366 msgid "stats-card" msgstr "" diff --git a/locales/templates/text-tracker.pot b/locales/templates/text-tracker.pot index d30eced7..ccf846bf 100644 --- a/locales/templates/text-tracker.pot +++ b/locales/templates/text-tracker.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,22 +17,22 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/tracking/text/cog.py:261 +#: src/tracking/text/cog.py:307 msgctxt "cmd:configure_message_exp" msgid "message_exp" msgstr "" -#: src/tracking/text/cog.py:264 +#: src/tracking/text/cog.py:310 msgctxt "cmd:configure_message_exp|desc" msgid "Configure Message Tracking & Experience" msgstr "" -#: src/tracking/text/cog.py:327 +#: src/tracking/text/cog.py:373 msgctxt "cmd:leo_configure_exp_rates" msgid "experience_rates" msgstr "" -#: src/tracking/text/cog.py:330 +#: src/tracking/text/cog.py:376 msgctxt "cmd:leo_configure_exp_rates|desc" msgid "Global experience rate configuration" msgstr "" diff --git a/locales/templates/timer-gui.pot b/locales/templates/timer-gui.pot index 436476f7..bbc4500f 100644 --- a/locales/templates/timer-gui.pot +++ b/locales/templates/timer-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/user_config.pot b/locales/templates/user_config.pot index 2f803d53..65e7b5f5 100644 --- a/locales/templates/user_config.pot +++ b/locales/templates/user_config.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/utils.pot b/locales/templates/utils.pot index 9d39b871..fbf0c2e7 100644 --- a/locales/templates/utils.pot +++ b/locales/templates/utils.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -28,77 +28,77 @@ msgid "" "Jump to a given page of the ouput of a previous command in this channel." msgstr "" -#: src/utils/cog.py:34 +#: src/utils/cog.py:35 msgctxt "cmd:page|error:no_pager" msgid "No pager listening in this channel!" msgstr "" -#: src/utils/cog.py:45 +#: src/utils/cog.py:46 msgctxt "cmd:page_next" msgid "next" msgstr "" -#: src/utils/cog.py:46 +#: src/utils/cog.py:47 msgctxt "cmd:page_next|desc" msgid "Jump to the next page of output." msgstr "" -#: src/utils/cog.py:52 +#: src/utils/cog.py:53 msgctxt "cmd:page_prev" msgid "prev" msgstr "" -#: src/utils/cog.py:53 +#: src/utils/cog.py:54 msgctxt "cmd:page_prev|desc" msgid "Jump to the previous page of output." msgstr "" -#: src/utils/cog.py:59 +#: src/utils/cog.py:60 msgctxt "cmd:page_first" msgid "first" msgstr "" -#: src/utils/cog.py:60 +#: src/utils/cog.py:61 msgctxt "cmd:page_first|desc" msgid "Jump to the first page of output." msgstr "" -#: src/utils/cog.py:66 +#: src/utils/cog.py:67 msgctxt "cmd:page_last" msgid "last" msgstr "" -#: src/utils/cog.py:67 +#: src/utils/cog.py:68 msgctxt "cmd:page_last|desc" msgid "Jump to the last page of output." msgstr "" -#: src/utils/cog.py:73 +#: src/utils/cog.py:74 msgctxt "cmd:page_select" msgid "select" msgstr "" -#: src/utils/cog.py:74 +#: src/utils/cog.py:75 msgctxt "cmd:page_select|desc" msgid "Select a page of the output to jump to." msgstr "" -#: src/utils/cog.py:77 +#: src/utils/cog.py:78 msgctxt "cmd:page_select|param:page" msgid "page" msgstr "" -#: src/utils/cog.py:80 +#: src/utils/cog.py:81 msgctxt "cmd:page_select|param:page|desc" msgid "The page name or number to jump to." msgstr "" -#: src/utils/cog.py:86 +#: src/utils/cog.py:87 msgctxt "cmd:page_select|error:no_pager" msgid "No pager listening in this channel!" msgstr "" -#: src/utils/cog.py:97 +#: src/utils/cog.py:98 msgctxt "cmd:page_select|acmpl|error:no_pager" msgid "No active pagers in this channel!" msgstr "" @@ -145,17 +145,17 @@ msgctxt "ui:configui|check|not_permitted" msgid "You have insufficient server permissions to use this UI!" msgstr "" -#: src/utils/ui/config.py:153 +#: src/utils/ui/config.py:156 msgctxt "ui:configui|button:edit|label" msgid "Edit" msgstr "" -#: src/utils/ui/config.py:192 +#: src/utils/ui/config.py:195 msgctxt "ui:guild_config_base|button:reset|label" msgid "Reset" msgstr "" -#: src/utils/ui/config.py:327 +#: src/utils/ui/config.py:330 msgctxt "ui:dashboard|error:section_too_long" msgid "" "Oops, the settings in this configuration section are too large, and I can " diff --git a/locales/templates/video.pot b/locales/templates/video.pot index cd8a6a37..be021cfd 100644 --- a/locales/templates/video.pot +++ b/locales/templates/video.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/voice-tracker.pot b/locales/templates/voice-tracker.pot index 14cd52a6..1cde3075 100644 --- a/locales/templates/voice-tracker.pot +++ b/locales/templates/voice-tracker.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,129 +17,175 @@ msgstr "" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" -#: src/tracking/voice/cog.py:661 +#: src/tracking/voice/session.py:293 +msgctxt "eventlog|event:voice_session_expired|title" +msgid "Member Voice Session Expired" +msgstr "" + +#: src/tracking/voice/session.py:297 +#, possible-python-brace-format +msgctxt "eventlog|event:voice_session_expired|desc" +msgid "" +"{member}'s voice session in {channel} expired because they reached the daily " +"voice cap." +msgstr "" + +#: src/tracking/voice/session.py:336 +msgctxt "eventlog|event:voice_session_closed|title" +msgid "Member Voice Session Ended" +msgstr "" + +#: src/tracking/voice/session.py:340 +#, possible-python-brace-format +msgctxt "eventlog|event:voice_session_closed|desc" +msgid "{member} completed their voice session in {channel}." +msgstr "" + +#: src/tracking/voice/session.py:352 +msgctxt "eventlog|event:voice_session_cancelled|title" +msgid "Member Voice Session Cancelled" +msgstr "" + +#: src/tracking/voice/session.py:356 +#, possible-python-brace-format +msgctxt "eventlog|event:voice_session_cancelled|desc" +msgid "{member} left {channel} before their voice session started." +msgstr "" + +#: src/tracking/voice/cog.py:518 +msgctxt "eventlog|event:voice_session_start|title" +msgid "Member Joined Tracked Voice Channel" +msgstr "" + +#: src/tracking/voice/cog.py:522 +#, possible-python-brace-format +msgctxt "eventlog|event:voice_session_start|desc" +msgid "{member} joined {channel}." +msgstr "" + +#: src/tracking/voice/cog.py:657 msgctxt "cmd:now" msgid "now" msgstr "" -#: src/tracking/voice/cog.py:664 +#: src/tracking/voice/cog.py:660 msgctxt "cmd:now|desc" msgid "" "Describe what you are working on, or see what your friends are working on!" msgstr "" -#: src/tracking/voice/cog.py:668 +#: src/tracking/voice/cog.py:664 msgctxt "cmd:now|param:tag" msgid "tag" msgstr "" -#: src/tracking/voice/cog.py:669 +#: src/tracking/voice/cog.py:665 msgctxt "cmd:now|param:user" msgid "user" msgstr "" -#: src/tracking/voice/cog.py:670 +#: src/tracking/voice/cog.py:666 msgctxt "cmd:now|param:clear" msgid "clear" msgstr "" -#: src/tracking/voice/cog.py:675 +#: src/tracking/voice/cog.py:671 msgctxt "cmd:now|param:tag|desc" msgid "Describe what you are working on in 10 characters or less!" msgstr "" -#: src/tracking/voice/cog.py:679 +#: src/tracking/voice/cog.py:675 msgctxt "cmd:now|param:user|desc" msgid "Check what a friend is working on." msgstr "" -#: src/tracking/voice/cog.py:683 +#: src/tracking/voice/cog.py:679 msgctxt "cmd:now|param:clear|desc" msgid "Unset your activity tag (or the target user's tag, for moderators)." msgstr "" -#: src/tracking/voice/cog.py:710 +#: src/tracking/voice/cog.py:706 msgctxt "cmd:now|target:self|error:target_inactive" msgid "" "You have no running session! Join a tracked voice channel to start a session." msgstr "" -#: src/tracking/voice/cog.py:719 +#: src/tracking/voice/cog.py:715 #, possible-python-brace-format msgctxt "cmd:now|target:other|error:target_inactive" msgid "{mention} has no running session!" msgstr "" -#: src/tracking/voice/cog.py:734 +#: src/tracking/voice/cog.py:730 msgctxt "cmd:now|target:self|mode:clear|success|title" msgid "Session Tag Cleared" msgstr "" -#: src/tracking/voice/cog.py:738 +#: src/tracking/voice/cog.py:734 msgctxt "cmd:now|target:self|mode:clear|success|desc" msgid "Successfully unset your session tag." msgstr "" -#: src/tracking/voice/cog.py:747 +#: src/tracking/voice/cog.py:743 msgctxt "cmd:now|target:other|mode:clear|error:perms|title" msgid "You can't do that!" msgstr "" -#: src/tracking/voice/cog.py:751 +#: src/tracking/voice/cog.py:747 msgctxt "cmd:now|target:other|mode:clear|error:perms|desc" msgid "You need to be a moderator to set or clear someone else's session tag." msgstr "" -#: src/tracking/voice/cog.py:761 +#: src/tracking/voice/cog.py:757 msgctxt "cmd:now|target:other|mode:clear|success|title" msgid "Session Tag Cleared!" msgstr "" -#: src/tracking/voice/cog.py:765 +#: src/tracking/voice/cog.py:761 #, possible-python-brace-format msgctxt "cmd:now|target:other|mode:clear|success|desc" msgid "Cleared {target}'s session tag." msgstr "" -#: src/tracking/voice/cog.py:777 +#: src/tracking/voice/cog.py:773 msgctxt "cmd:now|target:self|mode:set|success|title" msgid "Session Tag Set!" msgstr "" -#: src/tracking/voice/cog.py:781 +#: src/tracking/voice/cog.py:777 #, possible-python-brace-format msgctxt "cmd:now|target:self|mode:set|success|desc" msgid "You are now working on `{new_tag}`. Good luck!" msgstr "" -#: src/tracking/voice/cog.py:790 +#: src/tracking/voice/cog.py:786 msgctxt "cmd:now|target:other|mode:set|error:perms|title" msgid "You can't do that!" msgstr "" -#: src/tracking/voice/cog.py:794 +#: src/tracking/voice/cog.py:790 msgctxt "cmd:now|target:other|mode:set|error:perms|desc" msgid "You need to be a moderator to set or clear someone else's session tag!" msgstr "" -#: src/tracking/voice/cog.py:804 +#: src/tracking/voice/cog.py:800 msgctxt "cmd:now|target:other|mode:set|success|title" msgid "Session Tag Set!" msgstr "" -#: src/tracking/voice/cog.py:808 +#: src/tracking/voice/cog.py:804 #, possible-python-brace-format msgctxt "cmd:now|target:other|mode:set|success|desc" msgid "Set {target}'s session tag to `{new_tag}`." msgstr "" -#: src/tracking/voice/cog.py:817 +#: src/tracking/voice/cog.py:813 #, possible-python-brace-format msgctxt "cmd:now|target:self|mode:show_with_tag|desc" msgid "You have been working on **`{tag}`** in {channel} since {time}!" msgstr "" -#: src/tracking/voice/cog.py:822 +#: src/tracking/voice/cog.py:818 #, possible-python-brace-format msgctxt "cmd:now|target:self|mode:show_without_tag|desc" msgid "" @@ -148,7 +194,7 @@ msgid "" "Use `/now ` to set what you are working on." msgstr "" -#: src/tracking/voice/cog.py:829 +#: src/tracking/voice/cog.py:825 #, possible-python-brace-format msgctxt "cmd:now|target:other|mode:show_with_tag|desc" msgid "" @@ -156,23 +202,23 @@ msgid "" "They have been working on **{tag}** since {time}." msgstr "" -#: src/tracking/voice/cog.py:835 +#: src/tracking/voice/cog.py:831 #, possible-python-brace-format msgctxt "cmd:now|target:other|mode:show_without_tag|desc" msgid "{target} has been working in {channel} since {time}!" msgstr "" -#: src/tracking/voice/cog.py:858 +#: src/tracking/voice/cog.py:854 msgctxt "cmd:configure_voice_rates" msgid "voice_rewards" msgstr "" -#: src/tracking/voice/cog.py:861 +#: src/tracking/voice/cog.py:857 msgctxt "cmd:configure_voice_rates|desc" msgid "Configure Voice tracking rewards and experience" msgstr "" -#: src/tracking/voice/cog.py:918 +#: src/tracking/voice/cog.py:914 #, possible-python-brace-format msgctxt "cmd:configure_voice_tracking|mode:voice|resp:success|desc" msgid "" @@ -181,7 +227,7 @@ msgid "" "day." msgstr "" -#: src/tracking/voice/cog.py:929 +#: src/tracking/voice/cog.py:925 #, possible-python-brace-format msgctxt "cmd:configure_voice_tracking|mode:study|resp:success|desc" msgid "" @@ -364,27 +410,27 @@ msgctxt "ui:voice_tracker_config|menu:untracked_channels|placeholder" msgid "Set Untracked Channels" msgstr "" -#: src/tracking/voice/settings.py:428 src/tracking/voice/settings.py:482 +#: src/tracking/voice/settings.py:428 src/tracking/voice/settings.py:485 msgctxt "ui:voice_tracker_config|mode:voice|embed|title" msgid "Voice Tracker Configuration Panel" msgstr "" -#: src/tracking/voice/settings.py:433 src/tracking/voice/settings.py:487 +#: src/tracking/voice/settings.py:433 src/tracking/voice/settings.py:490 msgctxt "ui:voice_tracker_config|mode:study|embed|title" msgid "Study Tracker Configuration Panel" msgstr "" -#: src/tracking/voice/settings.py:472 +#: src/tracking/voice/settings.py:475 msgctxt "ui:voice_tracker_config|menu:untracked_channels|placeholder" msgid "Select Untracked Channels" msgstr "" -#: src/tracking/voice/settings.py:528 +#: src/tracking/voice/settings.py:531 msgctxt "dash:voice_tracker|title" msgid "Voice Tracker Configuration ({commands[configure voice_rewards]})" msgstr "" -#: src/tracking/voice/settings.py:532 +#: src/tracking/voice/settings.py:535 msgctxt "dash:voice_tracking|dropdown|placeholder" msgid "Voice Activity Panel" msgstr "" diff --git a/locales/templates/wards.pot b/locales/templates/wards.pot index f2c43f64..5f0322f7 100644 --- a/locales/templates/wards.pot +++ b/locales/templates/wards.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/locales/templates/weekly-gui.pot b/locales/templates/weekly-gui.pot index dd8d27ba..3d008c5a 100644 --- a/locales/templates/weekly-gui.pot +++ b/locales/templates/weekly-gui.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-10-01 16:01+0300\n" +"POT-Creation-Date: 2023-10-15 15:58+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" From 22c12850ff27f4cb858e2f873a9e114f8694f323 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 15 Oct 2023 17:23:09 +0300 Subject: [PATCH 49/49] fix(voice): Fix expiry calculation. --- src/tracking/voice/cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tracking/voice/cog.py b/src/tracking/voice/cog.py index 2b1645d8..f85f138d 100644 --- a/src/tracking/voice/cog.py +++ b/src/tracking/voice/cog.py @@ -601,9 +601,9 @@ class VoiceTrackerCog(LionCog): start_time = now delay = 20 - remaining = cap - studied_today + remaining = max(cap - studied_today, 0) expiry = start_time + dt.timedelta(seconds=remaining) - if expiry > tomorrow: + if expiry >= tomorrow: expiry = tomorrow + dt.timedelta(seconds=cap) return (delay, start_time, expiry)