Merge branch 'rewrite' of cgithub:StudyLions/StudyLion into rewrite

This commit is contained in:
2023-10-15 15:11:32 +03:00
26 changed files with 1391 additions and 183 deletions

View File

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

View File

@@ -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"</{name}:1110834049204891730>"
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')

106
src/core/hooks.py Normal file
View File

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

View File

@@ -1,20 +1,85 @@
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,
),
'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),
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 +114,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 +142,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:
@@ -80,7 +172,7 @@ class LionGuild(Timezoned):
return GuildMode.StudyGuild
@property
def timezone(self) -> pytz.timezone:
def timezone(self) -> str:
return self.config.timezone.value
@property
@@ -93,3 +185,168 @@ 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 <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]]={},
errors: list[str]=[],
**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.
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
if possible.
These will be added before the `fields` given.
"""
t = self.bot.translator.t
# Build embed
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:
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,
)
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)
task.add_done_callback(self._tasks.discard)

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

@@ -1,4 +1,5 @@
import asyncio
import pytz
import datetime as dt
from typing import Optional
@@ -161,7 +162,7 @@ class Ticket:
embed = discord.Embed(
title=title,
description=data.content,
timestamp=data.created_at,
timestamp=data.created_at.replace(tzinfo=pytz.utc),
colour=discord.Colour.orange()
)
embed.add_field(

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

View File

@@ -35,6 +35,8 @@ class VideoTicket(Ticket):
**kwargs
)
await ticket_data.update(created_at=utc_now().replace(tzinfo=None))
lguild = await bot.core.lions.fetch_guild(member.guild.id, guild=member.guild)
new_ticket = cls(lguild, ticket_data)

View File

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

View File

@@ -505,10 +505,27 @@ class VoiceTrackerCog(LionCog):
logger.debug(
f"Scheduling voice session for member `{member.name}' <uid:{member.id}> "
f"in guild '{member.guild.name}' <gid: member.guild.id> "
f"in channel '{achannel}' <cid: {after.channel.id}>. "
f"in channel '{achannel}' <cid: {achannel.id}>. "
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)

View File

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

View File

@@ -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 <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
# 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 <uid:{self.userid}> in guild <gid:{self.guildid}> "
f"and channel <cid:{self.state.channelid}>."
)
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)
))