feat(core): Implement event log interface.
This commit is contained in:
@@ -1,6 +1,3 @@
|
|||||||
from .cog import CoreCog
|
|
||||||
from .config import ConfigCog
|
|
||||||
|
|
||||||
from babel.translator import LocalBabel
|
from babel.translator import LocalBabel
|
||||||
|
|
||||||
|
|
||||||
@@ -8,5 +5,8 @@ babel = LocalBabel('lion-core')
|
|||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
|
from .cog import CoreCog
|
||||||
|
from .config import ConfigCog
|
||||||
|
|
||||||
await bot.add_cog(CoreCog(bot))
|
await bot.add_cog(CoreCog(bot))
|
||||||
await bot.add_cog(ConfigCog(bot))
|
await bot.add_cog(ConfigCog(bot))
|
||||||
|
|||||||
@@ -1,20 +1,75 @@
|
|||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
import pytz
|
import pytz
|
||||||
import discord
|
import discord
|
||||||
|
import logging
|
||||||
|
|
||||||
from meta import LionBot
|
from meta import LionBot, conf
|
||||||
from utils.lib import Timezoned
|
from meta.logger import log_wrap
|
||||||
|
from utils.lib import Timezoned, utc_now
|
||||||
from settings.groups import ModelConfig, SettingDotDict
|
from settings.groups import ModelConfig, SettingDotDict
|
||||||
|
from babel.translator import ctx_locale
|
||||||
|
|
||||||
|
from .hooks import HookedChannel
|
||||||
from .data import CoreData
|
from .data import CoreData
|
||||||
|
from . import babel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# TODO: Import Settings for Config type hinting
|
# TODO: Import Settings for Config type hinting
|
||||||
pass
|
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):
|
class VoiceMode(Enum):
|
||||||
STUDY = 0
|
STUDY = 0
|
||||||
VOICE = 1
|
VOICE = 1
|
||||||
@@ -49,7 +104,16 @@ class LionGuild(Timezoned):
|
|||||||
No guarantee is made that the client is in the corresponding Guild,
|
No guarantee is made that the client is in the corresponding Guild,
|
||||||
or that the corresponding Guild even exists.
|
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
|
Config = GuildConfig
|
||||||
settings = Config.settings
|
settings = Config.settings
|
||||||
@@ -68,6 +132,24 @@ class LionGuild(Timezoned):
|
|||||||
# Avoids voice race-states
|
# Avoids voice race-states
|
||||||
self.voice_lock = asyncio.Lock()
|
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
|
@property
|
||||||
def guild(self):
|
def guild(self):
|
||||||
if self._guild is None:
|
if self._guild is None:
|
||||||
@@ -94,3 +176,148 @@ class LionGuild(Timezoned):
|
|||||||
if self.data.name != guild.name:
|
if self.data.name != guild.name:
|
||||||
await self.data.update(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 <gid: {self.guildid}>",
|
||||||
|
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 <gid: {self.guildid}> 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)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from modules.statistics.settings import StatisticsDashboard
|
|||||||
from modules.member_admin.settingui import MemberAdminDashboard
|
from modules.member_admin.settingui import MemberAdminDashboard
|
||||||
from modules.moderation.settingui import ModerationDashboard
|
from modules.moderation.settingui import ModerationDashboard
|
||||||
from modules.video_channels.settingui import VideoDashboard
|
from modules.video_channels.settingui import VideoDashboard
|
||||||
|
from modules.config.settingui import GeneralDashboard
|
||||||
|
|
||||||
|
|
||||||
from . import babel, logger
|
from . import babel, logger
|
||||||
@@ -35,7 +36,7 @@ class GuildDashboard(BasePager):
|
|||||||
Paged UI providing an overview of the guild configuration.
|
Paged UI providing an overview of the guild configuration.
|
||||||
"""
|
"""
|
||||||
pages = [
|
pages = [
|
||||||
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
|
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard, GeneralDashboard,),
|
||||||
(ModerationDashboard, VideoDashboard,),
|
(ModerationDashboard, VideoDashboard,),
|
||||||
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
||||||
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class GeneralSettingUI(ConfigUI):
|
|||||||
class GeneralDashboard(DashboardSection):
|
class GeneralDashboard(DashboardSection):
|
||||||
section_name = _p(
|
section_name = _p(
|
||||||
"dash:general|title",
|
"dash:general|title",
|
||||||
"General Dashboard Settings ({commands[configure general]})"
|
"General Configuration ({commands[configure general]})"
|
||||||
)
|
)
|
||||||
_option_name = _p(
|
_option_name = _p(
|
||||||
"dash:general|option|name",
|
"dash:general|option|name",
|
||||||
|
|||||||
Reference in New Issue
Block a user