From fe81945391a2ee6a15dffe0dd774cdfc8c4ba887 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 10 Oct 2023 16:05:57 +0300 Subject: [PATCH 01/11] 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 66e7c2f2e4ea334d8959c92552dbef6d1f0441c1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 11 Oct 2023 07:28:53 +0300 Subject: [PATCH 02/11] 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 03/11] 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 7b6290b73ed120bcc15788467b619c59c77607d6 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 14 Oct 2023 01:07:46 +0300 Subject: [PATCH 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 10/11] 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 11/11] 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()