Merge pull request #60 from StudyLions/feat-eventlog

This commit is contained in:
Interitio
2023-10-15 15:08:58 +03:00
committed by GitHub
24 changed files with 1387 additions and 182 deletions

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ from modules.statistics.settings import StatisticsDashboard
from modules.member_admin.settingui import MemberAdminDashboard
from modules.moderation.settingui import ModerationDashboard
from modules.video_channels.settingui import VideoDashboard
from modules.config.settingui import GeneralDashboard
from . import babel, logger
@@ -35,7 +36,7 @@ class GuildDashboard(BasePager):
Paged UI providing an overview of the guild configuration.
"""
pages = [
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard, GeneralDashboard,),
(ModerationDashboard, VideoDashboard,),
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
(TasklistDashboard, RoomDashboard, TimerDashboard,),

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.")

View File

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

View File

@@ -168,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"),

View File

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

View File

@@ -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()