Merge branch 'release' into pillow
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
110
src/modules/config/settings.py
Normal file
110
src/modules/config/settings.py
Normal 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
|
||||
105
src/modules/config/settingui.py
Normal file
105
src/modules/config/settingui.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...'
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
460
src/modules/statistics/achievements.py
Normal file
460
src/modules/statistics/achievements.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
62
src/modules/statistics/graphics/profilestats.py
Normal file
62
src/modules/statistics/graphics/profilestats.py
Normal 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user