Merge branch 'release' into pillow

This commit is contained in:
2023-10-15 17:57:56 +03:00
103 changed files with 4724 additions and 1677 deletions

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ from modules.statistics.settings import StatisticsDashboard
from modules.member_admin.settingui import MemberAdminDashboard
from modules.moderation.settingui import ModerationDashboard
from modules.video_channels.settingui import VideoDashboard
from modules.config.settingui import GeneralDashboard
from . import babel, logger
@@ -35,7 +36,7 @@ class GuildDashboard(BasePager):
Paged UI providing an overview of the guild configuration.
"""
pages = [
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard, GeneralDashboard,),
(ModerationDashboard, VideoDashboard,),
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
(TasklistDashboard, RoomDashboard, TimerDashboard,),
@@ -185,23 +186,28 @@ class GuildDashboard(BasePager):
# ----- UI Control -----
async def reload(self, *args):
self._cached_pages.clear()
if not self._original.is_expired():
if self._original and not self._original.is_expired():
await self.redraw()
else:
await self.close()
async def refresh(self):
await super().refresh()
await self.config_menu_refresh()
self._layout = [
self.set_layout(
(self.config_menu,),
(self.prev_page_button, self.next_page_button)
]
)
async def redraw(self, *args):
await self.refresh()
await self._original.edit_original_response(
**self.current_page.edit_args,
view=self
)
if self._original and not self._original.is_expired():
await self._original.edit_original_response(
**self.current_page.edit_args,
view=self
)
else:
await self.close()
async def run(self, interaction: discord.Interaction):
await self.refresh()

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):
@@ -227,7 +311,8 @@ class MemberAdminCog(LionCog):
logger.info(f"Cleared persisting roles for guild <gid:{guild.id}> because we left the guild.")
@LionCog.listener('on_guildset_role_persistence')
async def clear_stored_roles(self, guildid, data):
async def clear_stored_roles(self, guildid, setting: MemberAdminSettings.RolePersistence):
data = setting.data
if data is False:
await self.data.past_roles.delete_where(guildid=guildid)
logger.info(

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

@@ -73,7 +73,7 @@ class TimerCog(LionCog):
launched=sum(1 for timer in timers if timer._run_task and not timer._run_task.done()),
looping=sum(1 for timer in timers if timer._loop_task and not timer._loop_task.done()),
locked=sum(1 for timer in timers if timer._lock.locked()),
voice_locked=sum(1 for timer in timers if timer._voice_update_lock.locked()),
voice_locked=sum(1 for timer in timers if timer.voice_lock.locked()),
)
if not self.ready:
level = StatusLevel.STARTING
@@ -345,7 +345,7 @@ class TimerCog(LionCog):
@LionCog.listener('on_guildset_pomodoro_channel')
@log_wrap(action='Update Pomodoro Channels')
async def _update_pomodoro_channels(self, guildid: int, data: Optional[int]):
async def _update_pomodoro_channels(self, guildid: int, setting: TimerSettings.PomodoroChannel):
"""
Request a send_status for all guild timers which need to move channel.
"""

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

@@ -136,6 +136,10 @@ class Timer:
channel = self.channel
return channel
@property
def voice_lock(self):
return self.lguild.voice_lock
async def get_notification_webhook(self) -> Optional[discord.Webhook]:
channel = self.notification_channel
if channel:
@@ -474,14 +478,13 @@ class Timer:
async with self.lguild.voice_lock:
try:
if self.guild.voice_client:
print("Disconnecting")
await self.guild.voice_client.disconnect(force=True)
print("Disconnected")
alert_file = focus_alert_path if stage.focused else break_alert_path
try:
print("Connecting")
voice_client = await self.channel.connect(timeout=60, reconnect=False)
print("Connected")
voice_client = await asyncio.wait_for(
self.channel.connect(timeout=30, reconnect=False),
timeout=60
)
except asyncio.TimeoutError:
logger.warning(f"Timed out while connecting to voice channel in timer {self!r}")
return
@@ -508,13 +511,18 @@ class Timer:
_, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
if self.guild and self.guild.voice_client:
await self.guild.voice_client.disconnect(force=True)
except asyncio.TimeoutError:
logger.warning(
f"Timed out while sending voice alert for timer {self!r}",
exc_info=True
)
except Exception:
logger.exception(
f"Exception occurred while playing voice alert for timer {self!r}"
)
finally:
if self.guild and self.guild.voice_client:
await self.guild.voice_client.disconnect(force=True)
def stageline(self, stage: Stage):
t = self.bot.translator.t
@@ -777,7 +785,7 @@ class Timer:
logger.info(f"Timer {self!r} has stopped. Auto restart is {'on' if auto_restart else 'off'}")
@log_wrap(action="Destroy Timer")
async def destroy(self, reason: str = None):
async def destroy(self, reason: Optional[str] = None):
"""
Deconstructs the timer, stopping all tasks.
"""

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

@@ -6,7 +6,7 @@ from discord.ui.select import select, Select, SelectOption, RoleSelect
from discord.ui.button import button, Button, ButtonStyle
from meta import conf, LionBot
from meta.errors import ResponseTimedOut
from meta.errors import ResponseTimedOut, SafeCancellation
from core.data import RankType
from data import ORDER
@@ -16,7 +16,7 @@ from wards import equippable_role
from babel.translator import ctx_translator
from .. import babel, logger
from ..data import AnyRankData
from ..data import AnyRankData, RankData
from ..utils import rank_model_from_type, format_stat_range, stat_data_to_value
from .editor import RankEditor
from .preview import RankPreviewUI
@@ -101,6 +101,7 @@ class RankOverviewUI(MessageUI):
Refresh the current ranks,
ensuring that all members have the correct rank.
"""
await press.response.defer(thinking=True)
async with self.cog.ranklock(self.guild.id):
await self.cog.interactive_rank_refresh(press, self.guild)
@@ -156,11 +157,21 @@ class RankOverviewUI(MessageUI):
Errors if the client does not have permission to create roles.
"""
t = self.bot.translator.t
if not self.guild.me.guild_permissions.manage_roles:
raise SafeCancellation(t(_p(
'ui:rank_overview|button:create|error:my_permissions',
"I lack the 'Manage Roles' permission required to create rank roles!"
)))
async def _create_callback(rank, submit: discord.Interaction):
await submit.response.send_message(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description="Rank Created!"
description=t(_p(
'ui:rank_overview|button:create|success',
"Created a new rank {role}"
)).format(role=f"<@&{rank.roleid}>")
),
ephemeral=True
)

View File

@@ -447,7 +447,7 @@ class Reminders(LionCog):
))
value = 'None'
choices = [
appcmds.Choice(name=name, value=value)
appcmds.Choice(name=name[:100], value=value)
]
else:
# Build list of reminder strings
@@ -463,7 +463,7 @@ class Reminders(LionCog):
# Build list of valid choices
choices = [
appcmds.Choice(
name=string[0],
name=string[0][:100],
value=f"rid:{string[1].reminderid}"
)
for string in matches
@@ -474,7 +474,7 @@ class Reminders(LionCog):
name=t(_p(
'cmd:reminders_cancel|acmpl:reminder|error:no_matches',
"You do not have any reminders matching \"{partial}\""
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
]
@@ -562,7 +562,7 @@ class Reminders(LionCog):
name=t(_p(
'cmd:remindme_at|acmpl:time|error:parse',
"Cannot parse \"{partial}\" as a time. Try the format HH:MM or YYYY-MM-DD HH:MM"
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
return [choice]

View File

@@ -14,10 +14,12 @@ from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation
from meta.sharding import THIS_SHARD
from utils.lib import utc_now, error_embed
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
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
@@ -142,6 +144,9 @@ class RoleMenuCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(RoleMenuData())
self.monitor = ComponentMonitor('RoleMenus', self._monitor)
self.ready = asyncio.Event()
# Menu caches
self.live_menus = RoleMenu.attached_menus # guildid -> messageid -> menuid
@@ -149,11 +154,42 @@ class RoleMenuCog(LionCog):
# Expiry manage
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
async def _monitor(self):
state = (
"<"
"RoleMenus"
" ready={ready}"
" cached={cached}"
" views={views}"
" live={live}"
" expiry={expiry}"
">"
)
data = dict(
ready=self.ready.is_set(),
live=sum(len(gmenus) for gmenus in self.live_menus.values()),
expiry=repr(self.expiry_monitor),
cached=len(RoleMenu._menus),
views=len(RoleMenu.menu_views),
)
if not self.ready.is_set():
level = StatusLevel.STARTING
info = f"(STARTING) Not initialised. {state}"
elif not self.expiry_monitor._monitor_task:
level = StatusLevel.ERRORED
info = f"(ERRORED) Expiry monitor not running. {state}"
else:
level = StatusLevel.OKAY
info = f"(OK) RoleMenu loaded and listening. {state}"
return ComponentStatus(level, info, info, data)
# ----- Initialisation -----
async def cog_load(self):
self.bot.system_monitor.add_component(self.monitor)
await self.data.init()
self.bot.tree.add_command(rolemenu_ctxcmd)
self.bot.tree.add_command(rolemenu_ctxcmd, override=True)
if self.bot.is_ready():
await self.initialise()
@@ -164,17 +200,28 @@ class RoleMenuCog(LionCog):
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
self.bot.tree.remove_command(rolemenu_ctxcmd)
@LionCog.listener('on_ready')
@log_wrap(action="Initialise Role Menus")
async def initialise(self):
self.ready.clear()
# Clean up live menu tasks
for menu in list(RoleMenu._menus.values()):
menu.detach()
self.live_menus.clear()
if self.expiry_monitor._monitor_task:
self.expiry_monitor._monitor_task.cancel()
# Start monitor
self.expiry_monitor = ExpiryMonitor(executor=self._expire)
self.expiry_monitor.start()
# Load guilds
guildids = [guild.id for guild in self.bot.guilds]
if guildids:
await self._initialise_guilds(*guildids)
self.ready.set()
async def _initialise_guilds(self, *guildids):
"""
@@ -269,14 +316,85 @@ 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)
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.")
pass
# ----- Private Utils -----
@@ -304,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',
@@ -487,7 +605,7 @@ class RoleMenuCog(LionCog):
choice_name = menu.data.name
choice_value = f"menuid:{menu.data.menuid}"
choices.append(
appcmds.Choice(name=choice_name, value=choice_value)
appcmds.Choice(name=choice_name[:100], value=choice_value)
)
if not choices:
@@ -498,7 +616,7 @@ class RoleMenuCog(LionCog):
)).format(partial=partial)
choice_value = partial
choice = appcmds.Choice(
name=choice_name, value=choice_value
name=choice_name[:100], value=choice_value
)
choices.append(choice)
@@ -522,7 +640,7 @@ class RoleMenuCog(LionCog):
"Please select a menu first"
))
choice_value = partial
choices = [appcmds.Choice(name=choice_name, value=choice_value)]
choices = [appcmds.Choice(name=choice_name[:100], value=choice_value)]
else:
# Resolve the menu name
menu: RoleMenu
@@ -544,7 +662,7 @@ class RoleMenuCog(LionCog):
name=t(_p(
'acmpl:menuroles|choice:invalid_menu|name',
"Menu '{name}' does not exist!"
)).format(name=menu_name),
)).format(name=menu_name)[:100],
value=partial
)
choices = [choice]
@@ -564,7 +682,7 @@ class RoleMenuCog(LionCog):
else:
name = mrole.data.label
choice = appcmds.Choice(
name=name,
name=name[:100],
value=f"<@&{mrole.data.roleid}>"
)
choices.append(choice)
@@ -573,7 +691,7 @@ class RoleMenuCog(LionCog):
name=t(_p(
'acmpl:menuroles|choice:no_matching|name',
"No roles in this menu matching '{partial}'"
)).format(partial=partial),
)).format(partial=partial)[:100],
value=partial
)
return choices[:25]

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,19 +168,34 @@ 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
@LionCog.listener('on_guildset_rooms_category')
@log_wrap(action='Update Rooms Category')
async def _update_rooms_category(self, guildid: int, data: Optional[int]):
async def _update_rooms_category(self, guildid: int, setting: RoomSettings.Category):
"""
Move all active private channels to the new category.
This shouldn't affect the channel function at all.
"""
data = setting.data
guild = self.bot.get_guild(guildid)
new_category = guild.get_channel(data) if guild else None
new_category = guild.get_channel(data) if guild and data else None
if new_category:
tasks = []
for room in list(self._room_cache[guildid].values()):
@@ -196,10 +211,11 @@ class RoomCog(LionCog):
@LionCog.listener('on_guildset_rooms_visible')
@log_wrap(action='Update Rooms Visibility')
async def _update_rooms_visibility(self, guildid: int, data: bool):
async def _update_rooms_visibility(self, guildid: int, setting: RoomSettings.Visible):
"""
Update the everyone override on each room to reflect the new setting.
"""
data = setting.data
tasks = []
for room in list(self._room_cache[guildid].values()):
if room.channel:
@@ -226,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
@@ -245,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()
@@ -287,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
@@ -488,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:
@@ -543,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(
@@ -556,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

@@ -904,10 +904,10 @@ class ScheduleCog(LionCog):
if not interaction.guild or not isinstance(interaction.user, discord.Member):
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:book|error:not_in_guild',
"You need to be in a server to book sessions!"
),
))[:100],
value='None'
)
choices = [choice]
@@ -917,10 +917,10 @@ class ScheduleCog(LionCog):
blacklist_role = (await self.settings.BlacklistRole.get(interaction.guild.id)).value
if blacklist_role and blacklist_role in member.roles:
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:book|error:blacklisted',
"Cannot Book -- Blacklisted"
),
))[:100],
value='None'
)
choices = [choice]
@@ -947,7 +947,7 @@ class ScheduleCog(LionCog):
)
choices.append(
appcmds.Choice(
name=tzstring, value='None',
name=tzstring[:100], value='None',
)
)
@@ -968,7 +968,7 @@ class ScheduleCog(LionCog):
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
name=name[:100],
value=str(slotid)
)
)
@@ -978,11 +978,11 @@ class ScheduleCog(LionCog):
name=t(_p(
"cmd:schedule|acmpl:book|no_matching",
"No bookable sessions matching '{partial}'"
)).format(partial=partial[:25]),
)).format(partial=partial[:25])[:100],
value=partial
)
)
return choices
return choices[:25]
@schedule_cmd.autocomplete('cancel')
async def schedule_cmd_cancel_acmpl(self, interaction: discord.Interaction, partial: str):
@@ -998,10 +998,10 @@ class ScheduleCog(LionCog):
can_cancel = list(slotid for slotid in schedule if slotid > minid)
if not can_cancel:
choice = appcmds.Choice(
name=_p(
name=t(_p(
'cmd:schedule|acmpl:cancel|error:empty_schedule',
"You do not have any upcoming sessions to cancel!"
),
))[:100],
value='None'
)
choices.append(choice)
@@ -1025,7 +1025,7 @@ class ScheduleCog(LionCog):
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
name=name[:100],
value=str(slotid)
)
)
@@ -1034,7 +1034,7 @@ class ScheduleCog(LionCog):
name=t(_p(
'cmd:schedule|acmpl:cancel|error:no_matching',
"No cancellable sessions matching '{partial}'"
)).format(partial=partial[:25]),
)).format(partial=partial[:25])[:100],
value='None'
)
choices.append(choice)

View File

@@ -442,7 +442,7 @@ class ScheduledSession:
'session|notify|dm|join_line:channels',
"Please attend your session by joining one of the following:"
))
join_line = '\n'.join(join_line, *(channel.mention for channel in valid[:20]))
join_line = '\n'.join((join_line, *(channel.mention for channel in valid[:20])))
if len(valid) > 20:
join_line += '\n...'

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()
@@ -446,7 +463,7 @@ class ColourShopping(ShopCog):
),
ephemeral=True
)
await logger.warning(
logger.warning(
"Unexpected Discord exception occurred while creating a colour role.",
exc_info=True
)
@@ -469,8 +486,13 @@ class ColourShopping(ShopCog):
# Due to the imprecise nature of Discord role ordering, this may fail.
try:
role = await role.edit(position=position)
except discord.Forbidden:
position = 0
except discord.HTTPException as e:
if e.code == 50013 or e.status == 403:
# Forbidden case
# But Discord sends its 'Missing Permissions' with a 400 code for position issues
position = 0
else:
raise
# Now that the role is set up, add it to data
item = await self.data.ShopItem.create(
@@ -1090,7 +1112,7 @@ class ColourShopping(ShopCog):
for i, item in enumerate(items, start=1)
]
options = [option for option in options if partial.lower() in option[1].lower()]
return [appcmds.Choice(name=option[1], value=option[0]) for option in options]
return [appcmds.Choice(name=option[1][:100], value=option[0]) for option in options]
class ColourStore(Store):

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

@@ -122,7 +122,7 @@ class StatsData(Registry):
"SELECT study_time_between(%s, %s, %s, %s)",
(guildid, userid, _start, _end)
)
return (await cursor.fetchone()[0]) or 0
return (await cursor.fetchone())[0] or 0
@classmethod
@log_wrap(action='study_times_between')
@@ -162,11 +162,11 @@ class StatsData(Registry):
"SELECT study_time_since(%s, %s, %s)",
(guildid, userid, _start)
)
return (await cursor.fetchone()[0]) or 0
return (await cursor.fetchone())[0] or 0
@classmethod
@log_wrap(action='study_times_since')
async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> int:
async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> list[int]:
if len(starts) < 1:
raise ValueError('No starting points given!')
@@ -251,7 +251,7 @@ class StatsData(Registry):
return leaderboard
@classmethod
@log_wrap('leaderboard_all')
@log_wrap(action='leaderboard_all')
async def leaderboard_all(cls, guildid: int):
"""
Return the all-time voice totals for the given guild.

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

@@ -41,7 +41,7 @@ class StatsUI(LeoUI):
"""
ID of guild to render stats for, or None if global.
"""
return self.guild.id if not self._showing_global else None
return self.guild.id if self.guild and not self._showing_global else None
@property
def userid(self) -> int:
@@ -67,7 +67,8 @@ class StatsUI(LeoUI):
Delete the output message and close the UI.
"""
await press.response.defer()
await self._original.delete_original_response()
if self._original and not self._original.is_expired():
await self._original.delete_original_response()
self._original = None
await self.close()
@@ -93,7 +94,10 @@ class StatsUI(LeoUI):
args = await self.make_message()
if thinking is not None and not thinking.is_expired() and thinking.response.is_done():
asyncio.create_task(thinking.delete_original_response())
await self._original.edit_original_response(**args.edit_args, view=self)
if self._original and not self._original.is_expired():
await self._original.edit_original_response(**args.edit_args, view=self)
else:
await self.close()
async def refresh(self, thinking: Optional[discord.Interaction] = None):
"""

View File

@@ -41,6 +41,7 @@ class StatType(IntEnum):
class LeaderboardUI(StatsUI):
page_size = 10
guildid: int
def __init__(self, bot, user, guild, **kwargs):
super().__init__(bot, user, guild, **kwargs)
@@ -199,6 +200,9 @@ class LeaderboardUI(StatsUI):
mode = CardMode.TEXT
elif self.stat_type is StatType.ANKI:
mode = CardMode.ANKI
else:
raise ValueError
card = await get_leaderboard_card(
self.bot, self.userid, self.guildid,
mode,

View File

@@ -166,7 +166,7 @@ class ProfileUI(StatsUI):
t = self.bot.translator.t
data: StatsData = self.bot.get_cog('StatsCog').data
tags = await data.ProfileTag.fetch_tags(self.guildid, self.userid)
tags = await data.ProfileTag.fetch_tags(self.guild.id, self.userid)
modal = ProfileEditor()
modal.editor.default = '\n'.join(tags)
@@ -177,7 +177,7 @@ class ProfileUI(StatsUI):
await interaction.response.defer(thinking=True, ephemeral=True)
# Set the new tags and refresh
await data.ProfileTag.set_tags(self.guildid, self.userid, new_tags)
await data.ProfileTag.set_tags(self.guild.id, self.userid, new_tags)
if self._original is not None:
self._profile_card = None
await self.refresh(thinking=interaction)
@@ -310,7 +310,7 @@ class ProfileUI(StatsUI):
"""
Create and render the XP and stats cards.
"""
card = await get_profile_card(self.bot, self.userid, self.guildid)
card = await get_profile_card(self.bot, self.userid, self.guild.id)
if card:
await card.render()
self._profile_card = card

View File

@@ -329,7 +329,7 @@ class Exec(LionCog):
results = [
appcmd.Choice(name=f"No peers found matching {partial}", value=partial)
]
return results
return results[:25]
async_cmd.autocomplete('target')(_peer_acmpl)

View File

@@ -242,6 +242,7 @@ class PresenceCtrl(LionCog):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
leo_setting_cog.bot_setting_groups.append(self.settings)
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
await self.reload_presence()
self.update_listeners()
@@ -372,7 +373,12 @@ class PresenceCtrl(LionCog):
"Unhandled exception occurred running client presence update loop. Closing loop."
)
@cmds.hybrid_command(
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def leo_group(self, ctx: LionContext):
...
@leo_group.command(
name="presence",
description="Globally set the bot status and activity."
)

View File

@@ -291,7 +291,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|error:no_tasks',
"Tasklist empty! No matching tasks."
)),
))[:100],
value=partial
)
]
@@ -319,7 +319,7 @@ class TasklistCog(LionCog):
if matching:
# If matches were found, assume user wants one of the matches
options = [
appcmds.Choice(name=task_string, value=label)
appcmds.Choice(name=task_string[:100], value=label)
for label, task_string in matching
]
elif multi and partial.lower().strip() in ('-', 'all'):
@@ -328,7 +328,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|match:all',
"All tasks"
)),
))[:100],
value='-'
)
]
@@ -353,7 +353,7 @@ class TasklistCog(LionCog):
multi_name = f"{partial[:remaining-1]} {error}"
multi_option = appcmds.Choice(
name=multi_name,
name=multi_name[:100],
value=partial
)
options = [multi_option]
@@ -371,7 +371,7 @@ class TasklistCog(LionCog):
if not matching:
matching = [(label, task) for label, task in labels if last_split.lower() in task.lower()]
options.extend(
appcmds.Choice(name=task_string, value=label)
appcmds.Choice(name=task_string[:100], value=label)
for label, task_string in matching
)
else:
@@ -380,7 +380,7 @@ class TasklistCog(LionCog):
name=t(_p(
'argtype:taskid|error:no_matching',
"No tasks matching '{partial}'!",
)).format(partial=partial[:100]),
)).format(partial=partial[:100])[:100],
value=partial
)
]

View File

@@ -728,7 +728,7 @@ class TasklistUI(BasePager):
)
try:
await press.user.send(contents, file=file, silent=True)
except discord.HTTPClient:
except discord.HTTPException:
fp.seek(0)
file = discord.File(fp, filename='tasklist.md')
await press.followup.send(
@@ -736,7 +736,7 @@ class TasklistUI(BasePager):
'ui:tasklist|button:save|error:dms',
"Could not DM you! Do you have me blocked? Tasklist attached below."
)),
file=file
file=file,
)
else:
fp.seek(0)

View File

@@ -393,7 +393,7 @@ class VideoCog(LionCog):
only_warn = True
# Ack based on ticket created
alert_ref = message.to_reference(fail_if_not_exists=False)
alert_ref = message.to_reference(fail_if_not_exists=False) if message else None
if only_warn:
# TODO: Warn ticket
warning = discord.Embed(

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)