Merge pull request #61 from StudyLions/rewrite

This commit is contained in:
Interitio
2023-10-15 15:56:30 +03:00
committed by GitHub
41 changed files with 2880 additions and 536 deletions

4
.gitmodules vendored
View File

@@ -1,6 +1,6 @@
[submodule "bot/gui"]
path = src/gui
url = cgithub:StudyLions/StudyLion-Plugin-GUI.git
url = https://github.com/StudyLions/StudyLion-Plugin-GUI.git
[submodule "skins"]
path = skins
url = cgithub:StudyLions/StudyLion-Plugin-Skins.git
url = https://github.com/StudyLions/StudyLion-Plugin-Skins.git

File diff suppressed because it is too large Load Diff

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

View File

@@ -373,3 +373,6 @@ class CoreData(Registry, name="core"):
webhook = discord.Webhook.partial(self.webhookid, self.token, **kwargs)
webhook.proxy = conf.bot.get('proxy', None)
return webhook
workouts = Table('workout_sessions')
topgg = Table('topgg')

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

@@ -150,7 +150,10 @@ class Lions(LionCog):
if (lmember := self.lion_members.get(key, None)) is None:
lguild = await self.fetch_guild(guildid, member.guild if member is not None else None)
luser = await self.fetch_user(userid, member)
data = await self.data.Member.fetch_or_create(guildid, userid)
data = await self.data.Member.fetch_or_create(
guildid, userid,
coins=lguild.config.get('starting_funds').value
)
lmember = LionMember(self.bot, data, lguild, luser, member)
self.lion_members[key] = lmember
return lmember
@@ -182,8 +185,8 @@ class Lions(LionCog):
# Create any member rows that are still missing
if missing:
new_rows = await self.data.Member.table.insert_many(
('guildid', 'userid'),
*missing
('guildid', 'userid', 'coins'),
*((gid, uid, lguilds[gid].config.get('starting_funds').value) for gid, uid in missing)
).with_adapter(self.data.Member._make_rows)
rows = itertools.chain(rows, new_rows)

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

@@ -117,8 +117,20 @@ class LionMember(Timezoned):
f"<uid: {member.id}>, <gid: {member.guild.id}>, <rid: {role.id}>. "
f"Error: {repr(e)}",
)
else:
if role not in member.roles:
logger.info(
f"Removed role <rid: {role.id}> from member <uid: {self.userid}> in <gid: {self.guildid}>"
)
else:
logger.error(
f"Tried to remove role <rid: {role.id}> "
f"from member <uid: {self.userid}> in <gid: {self.guildid}>. "
"Role remove succeeded, but member still has the role."
)
else:
# Remove the role from persistent role storage
cog = self.bot.get_cog('MemberAdminCog')
if cog:
await cog.absent_remove_role(self.guildid, self.userid, role.id)
logger.info(f"Removed role <rid: {role.id}> from absent lion <uid: {self.userid}> in <gid: {self.guildid}>")

Submodule src/gui updated: 24e94d10e2...f2760218ef

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

@@ -56,6 +56,7 @@ class Economy(LionCog):
self.bot.core.guild_config.register_model_setting(self.settings.AllowTransfers)
self.bot.core.guild_config.register_model_setting(self.settings.CoinsPerXP)
self.bot.core.guild_config.register_model_setting(self.settings.StartingFunds)
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
@@ -298,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():
@@ -359,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',
@@ -781,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
@@ -847,11 +889,13 @@ class Economy(LionCog):
)
@appcmds.rename(
allow_transfers=EconomySettings.AllowTransfers._display_name,
coins_per_xp=EconomySettings.CoinsPerXP._display_name
coins_per_xp=EconomySettings.CoinsPerXP._display_name,
starting_funds=EconomySettings.StartingFunds._display_name,
)
@appcmds.describe(
allow_transfers=EconomySettings.AllowTransfers._desc,
coins_per_xp=EconomySettings.CoinsPerXP._desc
coins_per_xp=EconomySettings.CoinsPerXP._desc,
starting_funds=EconomySettings.StartingFunds._desc,
)
@appcmds.choices(
allow_transfers=[
@@ -863,7 +907,9 @@ class Economy(LionCog):
@moderator_ward
async def configure_economy(self, ctx: LionContext,
allow_transfers: Optional[appcmds.Choice[int]] = None,
coins_per_xp: Optional[appcmds.Range[int, 0, 2**15]] = None):
coins_per_xp: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
starting_funds: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
):
t = self.bot.translator.t
if not ctx.interaction:
return
@@ -872,6 +918,7 @@ class Economy(LionCog):
setting_allow_transfers = ctx.lguild.config.get('allow_transfers')
setting_coins_per_xp = ctx.lguild.config.get('coins_per_xp')
setting_starting_funds = ctx.lguild.config.get('starting_funds')
modified = []
if allow_transfers is not None:
@@ -882,6 +929,10 @@ class Economy(LionCog):
setting_coins_per_xp.data = coins_per_xp
await setting_coins_per_xp.write()
modified.append(setting_coins_per_xp)
if starting_funds is not None:
setting_starting_funds.data = starting_funds
await setting_starting_funds.write()
modified.append(setting_starting_funds)
if modified:
desc = '\n'.join(f"{conf.emojis.tick} {setting.update_message}" for setting in modified)

View File

@@ -15,6 +15,7 @@ from meta.config import conf
from meta.sharding import THIS_SHARD
from meta.logger import log_wrap
from core.data import CoreData
from core.setting_types import CoinSetting
from babel.translator import ctx_translator
from . import babel, logger
@@ -29,7 +30,7 @@ class EconomySettings(SettingGroup):
coins_per_100xp
allow_transfers
"""
class CoinsPerXP(ModelData, IntegerSetting):
class CoinsPerXP(ModelData, CoinSetting):
setting_id = 'coins_per_xp'
_display_name = _p('guildset:coins_per_xp', "coins_per_100xp")
@@ -111,3 +112,32 @@ class EconomySettings(SettingGroup):
coin=conf.emojis.coin
)
return formatted
class StartingFunds(ModelData, CoinSetting):
setting_id = 'starting_funds'
_display_name = _p('guildset:starting_funds', "starting_funds")
_desc = _p(
'guildset:starting_funds|desc',
"How many LionCoins should a member start with."
)
_long_desc = _p(
'guildset:starting_funds|long_desc',
"Members will be given this number of coins when they first interact with me, or first join the server."
)
_accepts = _p(
'guildset:starting_funds|accepts',
"Number of coins to give to new members."
)
_default = 0
_model = CoreData.Guild
_column = CoreData.Guild.starting_funds.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:starting_funds|set_response',
"New members will now start with {amount}"
)).format(amount=self.formatted)

View File

@@ -17,8 +17,9 @@ _p = babel._p
class EconomyConfigUI(ConfigUI):
setting_classes = (
EconomySettings.StartingFunds,
EconomySettings.CoinsPerXP,
EconomySettings.AllowTransfers
EconomySettings.AllowTransfers,
)
def __init__(self, bot: LionBot,
@@ -44,11 +45,9 @@ class EconomyConfigUI(ConfigUI):
async def reload(self):
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
coins_per_xp = lguild.config.get(self.settings.CoinsPerXP.setting_id)
allow_transfers = lguild.config.get(self.settings.AllowTransfers.setting_id)
self.instances = (
coins_per_xp,
allow_transfers
self.instances = tuple(
lguild.config.get(cls.setting_id)
for cls in self.setting_classes
)
async def refresh_components(self):
@@ -57,9 +56,9 @@ class EconomyConfigUI(ConfigUI):
self.close_button_refresh(),
self.reset_button_refresh(),
)
self._layout = [
self.set_layout(
(self.edit_button, self.reset_button, self.close_button),
]
)
class EconomyDashboard(DashboardSection):

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
@@ -308,19 +309,89 @@ class RoleMenuCog(LionCog):
If the bot is no longer in the server, ignores the expiry.
If the member is no longer in the server, removes the role from persisted roles, if applicable.
"""
logger.info(f"Expiring RoleMenu equipped role {equipid}")
logger.debug(f"Expiring RoleMenu equipped role {equipid}")
rows = await self.data.RoleMenuHistory.fetch_expiring_where(equipid=equipid)
if rows:
equip_row = rows[0]
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)
await lion.remove_role(role)
if (member := lion.member):
if role in member.roles:
logger.error(f"Expired {equipid}, but the member still has the role!")
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:
logger.info(
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.")
else:
# equipid is no longer valid or is not expiring
logger.info(f"RoleMenu equipped role {equipid} is no longer valid or is not expiring.")
@@ -351,7 +422,7 @@ class RoleMenuCog(LionCog):
error = t(_p(
'parse:message_link|suberror:no_perms',
"Insufficient permissions! I need the `MESSAGE_HISTORY` permission in {channel}."
)).format(channel=channel.menion)
)).format(channel=channel.mention)
else:
error = t(_p(
'parse:message_link|suberror:channel_dne',

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

@@ -0,0 +1,460 @@
from typing import Optional, TYPE_CHECKING
import asyncio
import datetime as dt
import pytz
import discord
from data import ORDER, NULL
from meta import conf, LionBot
from meta.logger import log_wrap
from babel.translator import LazyStr
from . import babel, logger
if TYPE_CHECKING:
from .cog import StatsCog
_p = babel._p
emojis = [
(conf.emojis.active_achievement_1, conf.emojis.inactive_achievement_1),
(conf.emojis.active_achievement_2, conf.emojis.inactive_achievement_2),
(conf.emojis.active_achievement_3, conf.emojis.inactive_achievement_3),
(conf.emojis.active_achievement_4, conf.emojis.inactive_achievement_4),
(conf.emojis.active_achievement_5, conf.emojis.inactive_achievement_5),
(conf.emojis.active_achievement_6, conf.emojis.inactive_achievement_6),
(conf.emojis.active_achievement_7, conf.emojis.inactive_achievement_7),
(conf.emojis.active_achievement_8, conf.emojis.inactive_achievement_8),
]
def progress_bar(value, minimum, maximum, width=10) -> str:
"""
Build a text progress bar representing `value` between `minimum` and `maximum`.
"""
emojis = conf.emojis
proportion = (value - minimum) / (maximum - minimum)
sections = min(max(int(proportion * width), 0), width)
bar = []
# Starting segment
bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full))
# Full segments up to transition or end
if sections >= 2:
bar.append(str(emojis.progress_middle_full) * (sections - 2))
# Transition, if required
if 1 < sections < width:
bar.append(str(emojis.progress_middle_transition))
# Empty sections up to end
if sections < width:
bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1))
# End section
bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full))
# Join all the sections together and return
return ''.join(bar)
class Achievement:
"""
ABC for a member achievement.
"""
# Achievement title
_name: LazyStr
# Text describing achievement
_subtext: LazyStr
# Congratulations text
_congrats: LazyStr = _p(
'achievement|congrats',
"Congratulations! You have completed this challenge."
)
# Index used for visual display of achievement
emoji_index: int
# Achievement threshold
threshold: int
def __init__(self, bot: LionBot, guildid: int, userid: int):
self.bot = bot
self.guildid = guildid
self.userid = userid
self.value: Optional[int] = None
@property
def achieved(self) -> bool:
if self.value is None:
raise ValueError("Cannot get achievement status with no value.")
return self.value >= self.threshold
@property
def progress_text(self) -> str:
if self.value is None:
raise ValueError("Cannot get progress text with no value.")
return f"{int(self.value)}/{int(self.threshold)}"
@property
def name(self) -> str:
return self.bot.translator.t(self._name)
@property
def subtext(self) -> str:
return self.bot.translator.t(self._subtext)
@property
def congrats(self) -> str:
return self.bot.translator.t(self._congrats)
@property
def emoji(self):
return emojis[self.emoji_index][int(not self.achieved)]
@classmethod
async def fetch(cls, bot: LionBot, guildid: int, userid: int):
self = cls(bot, guildid, userid)
await self.update()
return self
def make_field(self):
name = f"{self.emoji} {self.name} ({self.progress_text})"
value = "**0** {bar} **{threshold}**\n*{subtext}*".format(
subtext=self.congrats if self.achieved else self.subtext,
bar=progress_bar(self.value, 0, self.threshold),
threshold=self.threshold
)
return (name, value)
async def update(self):
self.value = await self._calculate()
async def _calculate(self) -> int:
raise NotImplementedError
class Workout(Achievement):
_name = _p(
'achievement:workout|name',
"It's about Power"
)
_subtext = _p(
'achievement:workout|subtext',
"Workout 50 times"
)
threshold = 50
emoji_index = 3
@log_wrap(action='Calc Workout')
async def _calculate(self):
"""
Count the number of completed workout sessions this user has.
"""
record = await self.bot.core.data.workouts.select_one_where(
guildid=self.guildid, userid=self.userid
).select(total='COUNT(*)')
return int(record['total'] or 0)
class VoiceHours(Achievement):
_name = _p(
'achievement:voicehours|name',
"Dream Big"
)
_subtext = _p(
'achievement:voicehours|subtext',
"Study a total of 1000 hours"
)
threshold = 1000
emoji_index = 0
@log_wrap(action='Calc VoiceHours')
async def _calculate(self):
"""
Returns the total number of hours this member has spent in voice.
"""
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
records = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(total='SUM(duration) / 3600').with_no_adapter()
hours = records[0]['total'] if records else 0
return int(hours or 0)
class VoiceStreak(Achievement):
_name = _p(
'achievement:voicestreak|name',
"Consistency is Key"
)
_subtext = _p(
'achievement:voicestreak|subtext',
"Reach a 100-day voice streak"
)
threshold = 100
emoji_index = 1
@log_wrap(action='Calc VoiceStreak')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
# TODO: make this more efficient by calc in database..
history = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(
'start_time', 'end_time'
).order_by('start_time', ORDER.DESC).with_no_adapter()
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
# Streak statistics
streak = 0
max_streak = 0
current_streak = None
day_attended = None
date = lion.today
daydiff = dt.timedelta(days=1)
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
if current_streak is None:
current_streak = streak
max_streak = max(max_streak, streak)
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
return max_streak if max_streak >= self.threshold else current_streak
class Voting(Achievement):
_name = _p(
'achievement:voting|name',
"We're a Team"
)
_subtext = _p(
'achievement:voting|subtext',
"Vote 100 times on top.gg"
)
threshold = 100
emoji_index = 6
@log_wrap(action='Calc Voting')
async def _calculate(self):
record = await self.bot.core.data.topgg.select_one_where(
userid=self.userid
).select(total='COUNT(*)')
return int(record['total'] or 0)
class VoiceDays(Achievement):
_name = _p(
'achievement:days|name',
"Aim For The Moon"
)
_subtext = _p(
'achievement:days|subtext',
"Join Voice on 90 different days"
)
threshold = 90
emoji_index = 2
@log_wrap(action='Calc VoiceDays')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
offset = int(lion.today.utcoffset().total_seconds())
records = await stats.data.VoiceSessionStats.table.select_where(
guildid=self.guildid, userid=self.userid
).select(
total="COUNT(DISTINCT(date_trunc('day', (start_time AT TIME ZONE 'utc') + interval '{} seconds')))".format(offset)
).with_no_adapter()
days = records[0]['total'] if records else 0
return int(days or 0)
class TasksComplete(Achievement):
_name = _p(
'achievement:tasks|name',
"One Step at a Time"
)
_subtext = _p(
'achievement:tasks|subtext',
"Complete 1000 tasks"
)
threshold = 1000
emoji_index = 7
@log_wrap(action='Calc TasksComplete')
async def _calculate(self):
cog = self.bot.get_cog('TasklistCog')
if cog is None:
raise ValueError("Cannot calc TasksComplete without Tasklist Cog")
records = await cog.data.Task.table.select_where(
cog.data.Task.completed_at != NULL,
userid=self.userid,
).select(
total="COUNT(*)"
).with_no_adapter()
completed = records[0]['total'] if records else 0
return int(completed or 0)
class ScheduledSessions(Achievement):
_name = _p(
'achievement:schedule|name',
"Be Accountable"
)
_subtext = _p(
'achievement:schedule|subtext',
"Attend 500 Scheduled Sessions"
)
threshold = 500
emoji_index = 4
@log_wrap(action='Calc ScheduledSessions')
async def _calculate(self):
cog = self.bot.get_cog('ScheduleCog')
if not cog:
raise ValueError("Cannot calc scheduled sessions without ScheduleCog.")
model = cog.data.ScheduleSessionMember
records = await model.table.select_where(
userid=self.userid, guildid=self.guildid, attended=True
).select(
total='COUNT(*)'
).with_no_adapter()
return int((records[0]['total'] or 0) if records else 0)
class MonthlyHours(Achievement):
_name = _p(
'achievement:monthlyhours|name',
"The 30 Days Challenge"
)
_subtext = _p(
'achievement:monthlyhours|subtext',
"Be active for 100 hours in a month"
)
threshold = 100
emoji_index = 5
@log_wrap(action='Calc MonthlyHours')
async def _calculate(self):
stats: 'StatsCog' = self.bot.get_cog('StatsCog')
lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
records = await stats.data.VoiceSessionStats.table.select_where(
userid=self.userid,
guildid=self.guildid,
).select(
_first='MIN(start_time)'
).with_no_adapter()
first_session = records[0]['_first'] if records else None
if not first_session:
return 0
# Build the list of month start timestamps
month_start = lion.month_start
months = [month_start.astimezone(pytz.utc)]
while month_start >= first_session:
month_start -= dt.timedelta(days=1)
month_start = month_start.replace(day=1)
months.append(month_start.astimezone(pytz.utc))
# Query the study times
times = await stats.data.VoiceSessionStats.study_times_between(
self.guildid, self.userid, *reversed(months), lion.now
)
max_time = max(times) // 3600
return max_time if max_time >= self.threshold else times[-1] // 3600
achievements = [
Workout,
VoiceHours,
VoiceStreak,
Voting,
VoiceDays,
TasksComplete,
ScheduledSessions,
MonthlyHours,
]
achievements.sort(key=lambda cls: cls.emoji_index)
@log_wrap(action='Get Achievements')
async def get_achievements_for(bot: LionBot, guildid: int, userid: int):
"""
Asynchronously fetch achievements for the given member.
"""
member_achieved = [
ach(bot, guildid, userid) for ach in achievements
]
update_tasks = [
asyncio.create_task(ach.update()) for ach in member_achieved
]
await asyncio.gather(*update_tasks)
return member_achieved

View File

@@ -8,14 +8,18 @@ from discord import app_commands as appcmds
from discord.ui.button import ButtonStyle
from meta import LionBot, LionCog, LionContext
from core.lion_guild import VoiceMode
from utils.lib import error_embed
from utils.ui import LeoUI, AButton, utc_now
from gui.base import CardMode
from wards import low_management_ward
from . import babel
from .data import StatsData
from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI
from .settings import StatisticsSettings, StatisticsConfigUI
from .graphics.profilestats import get_full_profile
from .achievements import get_achievements_for
_p = babel._p
@@ -43,7 +47,7 @@ class StatsCog(LionCog):
name=_p('cmd:me', "me"),
description=_p(
'cmd:me|desc',
"Display your personal profile and summary statistics."
"Edit your personal profile and see your statistics."
)
)
@appcmds.guild_only
@@ -53,6 +57,50 @@ class StatsCog(LionCog):
await ui.run(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
name=_p('cmd:profile', 'profile'),
description=_p(
'cmd:profile|desc',
"Display the target's profile and statistics summary."
)
)
@appcmds.rename(
member=_p('cmd:profile|param:member', "member")
)
@appcmds.describe(
member=_p(
'cmd:profile|param:member|desc', "Member to display profile for."
)
)
@appcmds.guild_only
async def profile_cmd(self, ctx: LionContext, member: Optional[discord.Member] = None):
if not ctx.guild:
return
if not ctx.interaction:
return
member = member if member is not None else ctx.author
if member.bot:
# TODO: Localise
await ctx.reply(
"Bots cannot have profiles!",
ephemeral=True
)
return
await ctx.interaction.response.defer(thinking=True)
# Ensure the lion exists
await self.bot.core.lions.fetch_member(member.guild.id, member.id, member=member)
if ctx.lguild.guild_mode.voice:
mode = CardMode.VOICE
else:
mode = CardMode.TEXT
profile_data = await get_full_profile(self.bot, member.id, member.guild.id, mode)
with profile_data:
file = discord.File(profile_data, 'profile.png')
await ctx.reply(file=file)
@cmds.hybrid_command(
name=_p('cmd:stats', "stats"),
description=_p(
@@ -105,6 +153,38 @@ class StatsCog(LionCog):
await ui.run(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
name=_p('cmd:achievements', 'achievements'),
description=_p(
'cmd:achievements|desc',
"View your progress towards the activity achievement awards!"
)
)
@appcmds.guild_only
async def achievements_cmd(self, ctx: LionContext):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
await ctx.interaction.response.defer(thinking=True)
achievements = await get_achievements_for(self.bot, ctx.guild.id, ctx.author.id)
embed = discord.Embed(
title=t(_p(
'cmd:achievements|embed:title',
"Achievements"
)),
colour=discord.Colour.orange()
)
for achievement in achievements:
name, value = achievement.make_field()
embed.add_field(
name=name, value=value, inline=False
)
await ctx.reply(embed=embed)
# Setting commands
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)

View File

@@ -8,6 +8,7 @@ from gui.cards import ProfileCard
from modules.ranks.cog import RankCog
from modules.ranks.utils import format_stat_range
from ..achievements import get_achievements_for
if TYPE_CHECKING:
from ..cog import StatsCog
@@ -17,11 +18,11 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
ranks: Optional[RankCog] = bot.get_cog('RankCog')
stats: Optional[StatsCog] = bot.get_cog('StatsCog')
if ranks is None or stats is None:
return
raise ValueError("Cannot get profile card without ranks and stats cog loaded.")
guild = bot.get_guild(guildid)
if guild is None:
return
raise ValueError(f"Cannot get profile card without guild {guildid}")
lion = await bot.core.lions.fetch_member(guildid, userid)
luser = lion.luser
@@ -76,14 +77,15 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
else:
next_rank = None
achievements = (0, 1, 2, 3)
achievements = await get_achievements_for(bot, guildid, userid)
achieved = tuple(ach.emoji_index for ach in achievements if ach.achieved)
card = ProfileCard(
user=username,
avatar=(userid, avatar),
coins=lion.data.coins, gems=luser.data.gems, gifts=0,
profile_badges=profile_badges,
achievements=achievements,
achievements=achieved,
current_rank=current_rank,
rank_progress=rank_progress,
next_rank=next_rank

View File

@@ -0,0 +1,62 @@
import asyncio
from io import BytesIO
from PIL import Image
from meta import LionBot
from gui.base import CardMode
from .stats import get_stats_card
from .profile import get_profile_card
card_gap = 10
async def get_full_profile(bot: LionBot, userid: int, guildid: int, mode: CardMode) -> BytesIO:
"""
Render both profile and stats for the target member in the given mode.
Combines the resulting cards into a single image and returns the image data.
"""
# Prepare cards for rendering
get_tasks = (
asyncio.create_task(get_stats_card(bot, userid, guildid, mode), name='get-stats-for-combined'),
asyncio.create_task(get_profile_card(bot, userid, guildid), name='get-profile-for-combined'),
)
stats_card, profile_card = await asyncio.gather(*get_tasks)
# Render cards
render_tasks = (
asyncio.create_task(stats_card.render(), name='render-stats-for-combined'),
asyncio.create_task(profile_card.render(), name='render=profile-for-combined'),
)
# Load the card data into images
stats_data, profile_data = await asyncio.gather(*render_tasks)
with BytesIO(stats_data) as stats_stream, BytesIO(profile_data) as profile_stream:
with Image.open(stats_stream) as stats_image, Image.open(profile_stream) as profile_image:
# Create a new blank image of the correct dimenstions
stats_bbox = stats_image.getbbox(alpha_only=False)
profile_bbox = profile_image.getbbox(alpha_only=False)
if stats_bbox is None or profile_bbox is None:
# Should be impossible, image is already checked by GUI client
raise ValueError("Could not combine, empty stats or profile image.")
combined = Image.new(
'RGBA',
(
max(stats_bbox[2], profile_bbox[2]),
stats_bbox[3] + card_gap + profile_bbox[3]
),
color=None
)
with combined:
combined.alpha_composite(profile_image)
combined.alpha_composite(stats_image, (0, profile_bbox[3] + card_gap))
results = BytesIO()
combined.save(results, format='PNG', compress_type=3, compress_level=1)
results.seek(0)
return results

View File

@@ -6,11 +6,28 @@ import discord
from meta import LionBot
from gui.cards import StatsCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from .. import babel
from ..data import StatsData
_p = babel._p
def format_time(seconds):
return "{:02}:{:02}".format(
int(seconds // 3600),
int(seconds % 3600 // 60)
)
def format_xp(messages, xp):
return f"{messages} ({xp} XP)"
async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode):
t = bot.translator.t
data: StatsData = bot.get_cog('StatsCog').data
# TODO: Workouts
@@ -32,28 +49,41 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
)
# Extract the study times for each period
if mode in (CardMode.STUDY, CardMode.VOICE):
if mode in (CardMode.STUDY, CardMode.VOICE, CardMode.ANKI):
model = data.VoiceSessionStats
refkey = (guildid or None, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
period_activity = await ref_since(*refkey, *period_timestamps)
period_strings = [format_time(activity) for activity in reversed(period_activity)]
month_activity = period_activity[1]
month_string = t(_p(
'gui:stats|mode:voice|month',
"{hours} hours"
)).format(hours=int(month_activity // 3600))
elif mode is CardMode.TEXT:
msgmodel = TextTrackerData.TextSessions
if guildid:
model = data.MemberExp
msg_since = msgmodel.member_messages_since
refkey = (guildid, userid)
else:
model = data.UserExp
msg_since = msgmodel.member_messages_between
refkey = (userid,)
ref_since = model.xp_since
ref_between = model.xp_between
else:
# TODO ANKI
model = data.VoiceSessionStats
refkey = (guildid, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
study_times = await ref_since(*refkey, *period_timestamps)
xp_period_activity = await ref_since(*refkey, *period_timestamps)
msg_period_activity = await msg_since(*refkey, *period_timestamps)
period_strings = [
format_xp(msgs, xp)
for msgs, xp in zip(reversed(msg_period_activity), reversed(xp_period_activity))
]
month_string = f"{xp_period_activity[1]} XP"
else:
raise ValueError(f"Mode {mode} not supported")
# Get leaderboard position
# TODO: Efficiency
@@ -89,7 +119,8 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
card = StatsCard(
(position, 0),
list(reversed(study_times)),
period_strings,
month_string,
100,
streaks,
skin={'mode': mode}

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

@@ -288,6 +288,42 @@ class TextTrackerData(Registry):
tuple(chain((userid, guildid), points))
)
return [r['messages'] or 0 for r in await cursor.fetchall()]
@classmethod
@log_wrap(action='user_messages_since')
async def user_messages_since(cls, userid: int, *points):
"""
Compute messages written between the given points.
"""
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(messages)
FROM text_sessions s
WHERE
s.userid = %s
AND s.start_time >= t._start
) AS messages
FROM
(VALUES {})
AS
t (_start)
ORDER BY t._start
"""
).format(
sql.SQL(', ').join(
sql.SQL("({})").format(sql.Placeholder()) for _ in points
)
)
async with cls._connector.connection() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((userid,), points))
)
return [r['messages'] or 0 for r in await cursor.fetchall()]
@classmethod
@log_wrap(action='msgs_leaderboard_all')

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

View File

@@ -108,6 +108,9 @@ class ConfigUI(LeoUI):
# Filter out settings which don't have input fields
items = [item for item in items if item][:5]
strings = [item.value for item in items]
if not items:
raise ValueError("Cannot make Config edit modal with no editable instances.")
modal = ConfigEditor(*items, title=t(self.edit_modal_title))
@modal.submit_callback()