From 50a1a9c8a12eff6f199add786ead9917323602b2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 11:27:20 +0300 Subject: [PATCH 01/21] 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 02/21] (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 03/21] 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 04/21] 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 05/21] (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 06/21] 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 07/21] 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 08/21] 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 09/21] 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 10/21] 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 11/21] 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 12/21] 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 13/21] 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 14/21] 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 15/21] 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 16/21] 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 17/21] 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 18/21] 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 19/21] 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 20/21] 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 21/21] 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()