Merge branch 'rewrite' of cgithub:StudyLions/StudyLion into rewrite
This commit is contained in:
@@ -1,6 +1,3 @@
|
|||||||
from .cog import CoreCog
|
|
||||||
from .config import ConfigCog
|
|
||||||
|
|
||||||
from babel.translator import LocalBabel
|
from babel.translator import LocalBabel
|
||||||
|
|
||||||
|
|
||||||
@@ -8,5 +5,8 @@ babel = LocalBabel('lion-core')
|
|||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
|
from .cog import CoreCog
|
||||||
|
from .config import ConfigCog
|
||||||
|
|
||||||
await bot.add_cog(CoreCog(bot))
|
await bot.add_cog(CoreCog(bot))
|
||||||
await bot.add_cog(ConfigCog(bot))
|
await bot.add_cog(ConfigCog(bot))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import discord.app_commands as appcmd
|
import discord.app_commands as appcmd
|
||||||
@@ -16,6 +17,7 @@ from .lion import Lions
|
|||||||
from .lion_guild import GuildConfig
|
from .lion_guild import GuildConfig
|
||||||
from .lion_member import MemberConfig
|
from .lion_member import MemberConfig
|
||||||
from .lion_user import UserConfig
|
from .lion_user import UserConfig
|
||||||
|
from .hooks import HookedChannel
|
||||||
|
|
||||||
|
|
||||||
class keydefaultdict(defaultdict):
|
class keydefaultdict(defaultdict):
|
||||||
@@ -54,6 +56,7 @@ class CoreCog(LionCog):
|
|||||||
self.app_cmd_cache: list[discord.app_commands.AppCommand] = []
|
self.app_cmd_cache: list[discord.app_commands.AppCommand] = []
|
||||||
self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {}
|
self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {}
|
||||||
self.mention_cache: dict[str, str] = keydefaultdict(self.mention_cmd)
|
self.mention_cache: dict[str, str] = keydefaultdict(self.mention_cmd)
|
||||||
|
self.hook_cache: WeakValueDictionary[int, HookedChannel] = WeakValueDictionary()
|
||||||
|
|
||||||
async def cog_load(self):
|
async def cog_load(self):
|
||||||
# Fetch (and possibly create) core data rows.
|
# Fetch (and possibly create) core data rows.
|
||||||
@@ -91,7 +94,7 @@ class CoreCog(LionCog):
|
|||||||
cache |= subcache
|
cache |= subcache
|
||||||
return cache
|
return cache
|
||||||
|
|
||||||
def mention_cmd(self, name):
|
def mention_cmd(self, name: str):
|
||||||
"""
|
"""
|
||||||
Create an application command mention for the given names.
|
Create an application command mention for the given names.
|
||||||
|
|
||||||
@@ -103,6 +106,12 @@ class CoreCog(LionCog):
|
|||||||
mention = f"</{name}:1110834049204891730>"
|
mention = f"</{name}:1110834049204891730>"
|
||||||
return mention
|
return mention
|
||||||
|
|
||||||
|
def hooked_channel(self, channelid: int):
|
||||||
|
if (hooked := self.hook_cache.get(channelid, None)) is None:
|
||||||
|
hooked = HookedChannel(self.bot, channelid)
|
||||||
|
self.hook_cache[channelid] = hooked
|
||||||
|
return hooked
|
||||||
|
|
||||||
async def cog_unload(self):
|
async def cog_unload(self):
|
||||||
await self.bot.remove_cog(self.lions.qualified_name)
|
await self.bot.remove_cog(self.lions.qualified_name)
|
||||||
self.bot.remove_listener(self.shard_update_guilds, name='on_guild_join')
|
self.bot.remove_listener(self.shard_update_guilds, name='on_guild_join')
|
||||||
|
|||||||
106
src/core/hooks.py
Normal file
106
src/core/hooks.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from meta import LionBot
|
||||||
|
|
||||||
|
from .data import CoreData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
MISSING = discord.utils.MISSING
|
||||||
|
|
||||||
|
|
||||||
|
class HookedChannel:
|
||||||
|
def __init__(self, bot: LionBot, channelid: int):
|
||||||
|
self.bot = bot
|
||||||
|
self.channelid = channelid
|
||||||
|
|
||||||
|
self.webhook: Optional[discord.Webhook] | MISSING = None
|
||||||
|
self.data: Optional[CoreData.LionHook] = None
|
||||||
|
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel(self) -> Optional[discord.TextChannel | discord.VoiceChannel | discord.StageChannel]:
|
||||||
|
if not self.bot.is_ready():
|
||||||
|
raise ValueError("Cannot get hooked channel before ready.")
|
||||||
|
channel = self.bot.get_channel(self.channelid)
|
||||||
|
if channel and not isinstance(channel, (discord.TextChannel, discord.VoiceChannel, discord.StageChannel)):
|
||||||
|
raise ValueError(f"Hooked channel expects GuildChannel not '{channel.__class__.__name__}'")
|
||||||
|
return channel
|
||||||
|
|
||||||
|
async def get_webhook(self) -> Optional[discord.Webhook]:
|
||||||
|
"""
|
||||||
|
Fetch the saved discord.Webhook for this channel.
|
||||||
|
|
||||||
|
Uses cached webhook if possible, but instantiates if required.
|
||||||
|
Does not create a new webhook, use `create_webhook` for that.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
if self.webhook is MISSING:
|
||||||
|
hook = None
|
||||||
|
elif self.webhook is None:
|
||||||
|
# Fetch webhook data
|
||||||
|
data = await CoreData.LionHook.fetch(self.channelid)
|
||||||
|
if data is not None:
|
||||||
|
# Instantiate Webhook
|
||||||
|
hook = self.webhook = data.as_webhook(client=self.bot)
|
||||||
|
else:
|
||||||
|
self.webhook = MISSING
|
||||||
|
hook = None
|
||||||
|
else:
|
||||||
|
hook = self.webhook
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
async def create_webhook(self, **creation_kwargs) -> Optional[discord.Webhook]:
|
||||||
|
"""
|
||||||
|
Create and save a new webhook in this channel.
|
||||||
|
|
||||||
|
Returns None if we could not create a new webhook.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
if self.webhook is not MISSING:
|
||||||
|
# Delete any existing webhook
|
||||||
|
if self.webhook is not None:
|
||||||
|
try:
|
||||||
|
await self.webhook.delete()
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
logger.info(
|
||||||
|
f"Ignoring exception while refreshing webhook for {self.channelid}: {repr(e)}"
|
||||||
|
)
|
||||||
|
await self.bot.core.data.LionHook.table.delete_where(channelid=self.channelid)
|
||||||
|
self.webhook = MISSING
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
channel = self.channel
|
||||||
|
if channel is not None and channel.permissions_for(channel.guild.me).manage_webhooks:
|
||||||
|
if 'avatar' not in creation_kwargs:
|
||||||
|
avatar = self.bot.user.avatar if self.bot.user else None
|
||||||
|
creation_kwargs['avatar'] = (await avatar.to_file()).fp.read() if avatar else None
|
||||||
|
webhook = await channel.create_webhook(**creation_kwargs)
|
||||||
|
self.data = await self.bot.core.data.LionHook.create(
|
||||||
|
channelid=self.channelid,
|
||||||
|
token=webhook.token,
|
||||||
|
webhookid=webhook.id,
|
||||||
|
)
|
||||||
|
self.webhook = webhook
|
||||||
|
return webhook
|
||||||
|
|
||||||
|
async def invalidate(self, webhook: discord.Webhook):
|
||||||
|
"""
|
||||||
|
Invalidate the given webhook.
|
||||||
|
|
||||||
|
To be used when the webhook has been deleted on the Discord side.
|
||||||
|
"""
|
||||||
|
async with self.lock:
|
||||||
|
if self.webhook is not None and self.webhook is not MISSING and self.webhook.id == webhook.id:
|
||||||
|
# Webhook provided matches current webhook
|
||||||
|
# Delete current webhook
|
||||||
|
self.webhook = MISSING
|
||||||
|
self.data = None
|
||||||
|
await self.bot.core.data.LionHook.table.delete_where(webhookid=webhook.id)
|
||||||
@@ -1,20 +1,85 @@
|
|||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
import pytz
|
import pytz
|
||||||
import discord
|
import discord
|
||||||
|
import logging
|
||||||
|
|
||||||
from meta import LionBot
|
from meta import LionBot, conf
|
||||||
from utils.lib import Timezoned
|
from meta.logger import log_wrap
|
||||||
|
from utils.lib import Timezoned, utc_now
|
||||||
from settings.groups import ModelConfig, SettingDotDict
|
from settings.groups import ModelConfig, SettingDotDict
|
||||||
|
from babel.translator import ctx_locale
|
||||||
|
|
||||||
|
from .hooks import HookedChannel
|
||||||
from .data import CoreData
|
from .data import CoreData
|
||||||
|
from . import babel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# TODO: Import Settings for Config type hinting
|
# TODO: Import Settings for Config type hinting
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
event_fields = {
|
||||||
|
'start': (
|
||||||
|
_p('eventlog|field:start|name', "Start"),
|
||||||
|
"{value}",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'expiry': (
|
||||||
|
_p('eventlog|field:expiry|name', "Expires"),
|
||||||
|
"{value}",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'roles_given' : (
|
||||||
|
_p('eventlog|field:roles_given|name', "Roles Given"),
|
||||||
|
"{value}",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'roles_taken' : (
|
||||||
|
_p('eventlog|field:roles_given|name', "Roles Taken"),
|
||||||
|
"{value}",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'coins_earned' : (
|
||||||
|
_p('eventlog|field:coins_earned|name', "Coins Earned"),
|
||||||
|
"{coin} {{value}}".format(coin=conf.emojis.coin),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'price' : (
|
||||||
|
_p('eventlog|field:price|name', "Price"),
|
||||||
|
"{coin} {{value}}".format(coin=conf.emojis.coin),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'balance' : (
|
||||||
|
_p('eventlog|field:balance|name', "Balance"),
|
||||||
|
"{coin} {{value}}".format(coin=conf.emojis.coin),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'refund' : (
|
||||||
|
_p('eventlog|field:refund|name', "Coins Refunded"),
|
||||||
|
"{coin} {{value}}".format(coin=conf.emojis.coin),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'memberid': (
|
||||||
|
_p('eventlog|field:memberid|name', "Member"),
|
||||||
|
"<@{value}>",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
'channelid': (
|
||||||
|
_p('eventlog|field:channelid|name', "Channel"),
|
||||||
|
"<#{value}>",
|
||||||
|
True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VoiceMode(Enum):
|
class VoiceMode(Enum):
|
||||||
STUDY = 0
|
STUDY = 0
|
||||||
VOICE = 1
|
VOICE = 1
|
||||||
@@ -49,7 +114,16 @@ class LionGuild(Timezoned):
|
|||||||
No guarantee is made that the client is in the corresponding Guild,
|
No guarantee is made that the client is in the corresponding Guild,
|
||||||
or that the corresponding Guild even exists.
|
or that the corresponding Guild even exists.
|
||||||
"""
|
"""
|
||||||
__slots__ = ('bot', 'data', 'guildid', 'config', '_guild', 'voice_lock', '__weakref__')
|
__slots__ = (
|
||||||
|
'bot', 'data',
|
||||||
|
'guildid',
|
||||||
|
'config',
|
||||||
|
'_guild',
|
||||||
|
'voice_lock',
|
||||||
|
'_eventlogger',
|
||||||
|
'_tasks',
|
||||||
|
'__weakref__'
|
||||||
|
)
|
||||||
|
|
||||||
Config = GuildConfig
|
Config = GuildConfig
|
||||||
settings = Config.settings
|
settings = Config.settings
|
||||||
@@ -68,6 +142,24 @@ class LionGuild(Timezoned):
|
|||||||
# Avoids voice race-states
|
# Avoids voice race-states
|
||||||
self.voice_lock = asyncio.Lock()
|
self.voice_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# HookedChannel managing the webhook used to send guild event logs
|
||||||
|
# May be None if no event log is set or if the channel does not exist
|
||||||
|
self._eventlogger: Optional[HookedChannel] = None
|
||||||
|
|
||||||
|
# Set of background tasks associated with this guild (e.g. event logs)
|
||||||
|
# In theory we should ensure these are finished before the lguild is gcd
|
||||||
|
# But this is *probably* not an actual problem in practice
|
||||||
|
self._tasks = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eventlogger(self) -> Optional[HookedChannel]:
|
||||||
|
channelid = self.data.event_log_channel
|
||||||
|
if channelid is None:
|
||||||
|
self._eventlogger = None
|
||||||
|
elif self._eventlogger is None or self._eventlogger.channelid != channelid:
|
||||||
|
self._eventlogger = self.bot.core.hooked_channel(channelid)
|
||||||
|
return self._eventlogger
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def guild(self):
|
def guild(self):
|
||||||
if self._guild is None:
|
if self._guild is None:
|
||||||
@@ -80,7 +172,7 @@ class LionGuild(Timezoned):
|
|||||||
return GuildMode.StudyGuild
|
return GuildMode.StudyGuild
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self) -> pytz.timezone:
|
def timezone(self) -> str:
|
||||||
return self.config.timezone.value
|
return self.config.timezone.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -93,3 +185,168 @@ class LionGuild(Timezoned):
|
|||||||
"""
|
"""
|
||||||
if self.data.name != guild.name:
|
if self.data.name != guild.name:
|
||||||
await self.data.update(name=guild.name)
|
await self.data.update(name=guild.name)
|
||||||
|
|
||||||
|
@log_wrap(action='get event hook')
|
||||||
|
async def get_event_hook(self) -> Optional[discord.Webhook]:
|
||||||
|
hooked = self.eventlogger
|
||||||
|
ctx_locale.set(self.locale)
|
||||||
|
|
||||||
|
if hooked:
|
||||||
|
hook = await hooked.get_webhook()
|
||||||
|
if hook is not None:
|
||||||
|
pass
|
||||||
|
elif (channel := hooked.channel) is None:
|
||||||
|
# Event log channel doesn't exist
|
||||||
|
pass
|
||||||
|
elif not channel.permissions_for(channel.guild.me).manage_webhooks:
|
||||||
|
# Cannot create a webhook here
|
||||||
|
if channel.permissions_for(channel.guild.me).send_messages:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
try:
|
||||||
|
await channel.send(t(_p(
|
||||||
|
'eventlog|error:manage_webhooks',
|
||||||
|
"This channel is configured as an event log, "
|
||||||
|
"but I am missing the 'Manage Webhooks' permission here."
|
||||||
|
)))
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# We should be able to create the hook
|
||||||
|
t = self.bot.translator.t
|
||||||
|
try:
|
||||||
|
hook = await hooked.create_webhook(
|
||||||
|
name=t(_p(
|
||||||
|
'eventlog|create|name',
|
||||||
|
"{bot_name} Event Log"
|
||||||
|
)).format(bot_name=channel.guild.me.name),
|
||||||
|
reason=t(_p(
|
||||||
|
'eventlog|create|audit_reason',
|
||||||
|
"Creating event log webhook"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
f"Unexpected exception while creating event log webhook for <gid: {self.guildid}>",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return hook
|
||||||
|
|
||||||
|
@log_wrap(action="Log Event")
|
||||||
|
async def _log_event(self, embed: discord.Embed, retry=True):
|
||||||
|
logger.debug(f"Logging event log event: {embed.to_dict()}")
|
||||||
|
|
||||||
|
hook = await self.get_event_hook()
|
||||||
|
if hook is not None:
|
||||||
|
try:
|
||||||
|
await hook.send(embed=embed)
|
||||||
|
except discord.NotFound:
|
||||||
|
logger.info(
|
||||||
|
f"Event log in <gid: {self.guildid}> invalidated. Recreating: {retry}"
|
||||||
|
)
|
||||||
|
hooked = self.eventlogger
|
||||||
|
if hooked is not None:
|
||||||
|
await hooked.invalidate(hook)
|
||||||
|
if retry:
|
||||||
|
await self._log_event(embed, retry=False)
|
||||||
|
except discord.HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
f"Discord exception occurred sending event log event: {embed.to_dict()}.",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Unknown exception occurred sending event log event: {embed.to_dict()}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_event(self,
|
||||||
|
title: Optional[str]=None, description: Optional[str]=None,
|
||||||
|
timestamp: Optional[dt.datetime]=None,
|
||||||
|
*,
|
||||||
|
embed: Optional[discord.Embed] = None,
|
||||||
|
fields: dict[str, tuple[str, bool]]={},
|
||||||
|
errors: list[str]=[],
|
||||||
|
**kwargs: str | int):
|
||||||
|
"""
|
||||||
|
Synchronously log an event to the guild event log.
|
||||||
|
|
||||||
|
Does nothing if the event log has not been set up.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
title: str
|
||||||
|
Embed title
|
||||||
|
description: str
|
||||||
|
Embed description
|
||||||
|
timestamp: dt.datetime
|
||||||
|
Embed timestamp. Defaults to `now` if not given.
|
||||||
|
embed: discord.Embed
|
||||||
|
Optional base embed to use.
|
||||||
|
May be used to completely customise log message.
|
||||||
|
fields: dict[str, tuple[str, bool]]
|
||||||
|
Optional embed fields to add.
|
||||||
|
errors: list[str]
|
||||||
|
Optional list of errors to add.
|
||||||
|
Errors will always be added last.
|
||||||
|
kwargs: str | int
|
||||||
|
Optional embed fields to add to the embed.
|
||||||
|
These differ from `fields` in that the kwargs keys will be automatically matched and localised
|
||||||
|
if possible.
|
||||||
|
These will be added before the `fields` given.
|
||||||
|
"""
|
||||||
|
t = self.bot.translator.t
|
||||||
|
|
||||||
|
# Build embed
|
||||||
|
if embed is not None:
|
||||||
|
base = embed
|
||||||
|
else:
|
||||||
|
base = discord.Embed(
|
||||||
|
colour=(discord.Colour.brand_red() if errors else discord.Colour.dark_orange())
|
||||||
|
)
|
||||||
|
if description is not None:
|
||||||
|
base.description = description
|
||||||
|
if title is not None:
|
||||||
|
base.title = title
|
||||||
|
if timestamp is not None:
|
||||||
|
base.timestamp = timestamp
|
||||||
|
else:
|
||||||
|
base.timestamp = utc_now()
|
||||||
|
|
||||||
|
# Add embed fields
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if key in event_fields:
|
||||||
|
_field_name, _field_value, inline = event_fields[key]
|
||||||
|
field_name = t(_field_name, locale=self.locale)
|
||||||
|
field_value = _field_value.format(value=value)
|
||||||
|
else:
|
||||||
|
field_name = key
|
||||||
|
field_value = value
|
||||||
|
inline = False
|
||||||
|
base.add_field(
|
||||||
|
name=field_name,
|
||||||
|
value=field_value,
|
||||||
|
inline=inline
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, (value, inline) in fields.items():
|
||||||
|
base.add_field(
|
||||||
|
name=key,
|
||||||
|
value=value,
|
||||||
|
inline=inline,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
error_name = t(_p(
|
||||||
|
'eventlog|field:errors|name',
|
||||||
|
"Errors"
|
||||||
|
))
|
||||||
|
error_value = '\n'.join(f"- {line}" for line in errors)
|
||||||
|
base.add_field(
|
||||||
|
name=error_name, value=error_value, inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send embed
|
||||||
|
task = asyncio.create_task(self._log_event(embed=base), name='event-log')
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ babel = LocalBabel('config')
|
|||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
from .general import GeneralSettingsCog
|
from .cog import GuildConfigCog
|
||||||
from .cog import DashCog
|
|
||||||
|
|
||||||
await bot.add_cog(GeneralSettingsCog(bot))
|
await bot.add_cog(GuildConfigCog(bot))
|
||||||
await bot.add_cog(DashCog(bot))
|
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands as appcmds
|
from discord import app_commands as appcmds
|
||||||
from discord.ext import commands as cmds
|
from discord.ext import commands as cmds
|
||||||
|
|
||||||
from meta import LionBot, LionContext, LionCog
|
from meta import LionBot, LionContext, LionCog
|
||||||
|
from wards import low_management_ward
|
||||||
|
|
||||||
from . import babel
|
from . import babel
|
||||||
from .dashboard import GuildDashboard
|
from .dashboard import GuildDashboard
|
||||||
|
from .settings import GeneralSettings
|
||||||
|
from .settingui import GeneralSettingUI
|
||||||
|
|
||||||
_p = babel._p
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
class DashCog(LionCog):
|
class GuildConfigCog(LionCog):
|
||||||
|
depends = {'CoreCog'}
|
||||||
|
|
||||||
def __init__(self, bot: LionBot):
|
def __init__(self, bot: LionBot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
self.settings = GeneralSettings()
|
||||||
|
|
||||||
async def cog_load(self):
|
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(
|
@cmds.hybrid_command(
|
||||||
name="dashboard",
|
name="dashboard",
|
||||||
@@ -27,6 +38,75 @@ class DashCog(LionCog):
|
|||||||
@appcmds.guild_only
|
@appcmds.guild_only
|
||||||
@appcmds.default_permissions(manage_guild=True)
|
@appcmds.default_permissions(manage_guild=True)
|
||||||
async def dashboard_cmd(self, ctx: LionContext):
|
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)
|
ui = GuildDashboard(self.bot, ctx.guild, ctx.author.id, ctx.channel.id)
|
||||||
await ui.run(ctx.interaction)
|
await ui.run(ctx.interaction)
|
||||||
await ui.wait()
|
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.member_admin.settingui import MemberAdminDashboard
|
||||||
from modules.moderation.settingui import ModerationDashboard
|
from modules.moderation.settingui import ModerationDashboard
|
||||||
from modules.video_channels.settingui import VideoDashboard
|
from modules.video_channels.settingui import VideoDashboard
|
||||||
|
from modules.config.settingui import GeneralDashboard
|
||||||
|
|
||||||
|
|
||||||
from . import babel, logger
|
from . import babel, logger
|
||||||
@@ -35,7 +36,7 @@ class GuildDashboard(BasePager):
|
|||||||
Paged UI providing an overview of the guild configuration.
|
Paged UI providing an overview of the guild configuration.
|
||||||
"""
|
"""
|
||||||
pages = [
|
pages = [
|
||||||
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard,),
|
(MemberAdminDashboard, LocaleDashboard, EconomyDashboard, GeneralDashboard,),
|
||||||
(ModerationDashboard, VideoDashboard,),
|
(ModerationDashboard, VideoDashboard,),
|
||||||
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
(VoiceTrackerDashboard, TextTrackerDashboard, RankDashboard, StatisticsDashboard,),
|
||||||
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
(TasklistDashboard, RoomDashboard, TimerDashboard,),
|
||||||
|
|||||||
@@ -26,48 +26,6 @@ from . import babel
|
|||||||
_p = babel._p
|
_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):
|
class GeneralSettingsCog(LionCog):
|
||||||
depends = {'CoreCog'}
|
depends = {'CoreCog'}
|
||||||
|
|
||||||
@@ -87,24 +45,28 @@ class GeneralSettingsCog(LionCog):
|
|||||||
@LionCog.placeholder_group
|
@LionCog.placeholder_group
|
||||||
@cmds.hybrid_group("configure", with_app_command=False)
|
@cmds.hybrid_group("configure", with_app_command=False)
|
||||||
async def configure_group(self, ctx: LionContext):
|
async def configure_group(self, ctx: LionContext):
|
||||||
# Placeholder configure group command.
|
# Placeholder configure group command.
|
||||||
...
|
...
|
||||||
|
|
||||||
@configure_group.command(
|
@configure_group.command(
|
||||||
name=_p('cmd:configure_general', "general"),
|
name=_p('cmd:configure_general', "general"),
|
||||||
description=_p('cmd:configure_general|desc', "General configuration panel")
|
description=_p('cmd:configure_general|desc', "General configuration panel")
|
||||||
)
|
)
|
||||||
@appcmds.rename(
|
@appcmds.rename(
|
||||||
timezone=GeneralSettings.Timezone._display_name
|
timezone=GeneralSettings.Timezone._display_name,
|
||||||
|
event_log=GeneralSettings.EventLog._display_name,
|
||||||
)
|
)
|
||||||
@appcmds.describe(
|
@appcmds.describe(
|
||||||
timezone=GeneralSettings.Timezone._desc
|
timezone=GeneralSettings.Timezone._desc,
|
||||||
|
event_log=GeneralSettings.EventLog._display_name,
|
||||||
)
|
)
|
||||||
@appcmds.guild_only()
|
@appcmds.guild_only()
|
||||||
@appcmds.default_permissions(manage_guild=True)
|
@appcmds.default_permissions(manage_guild=True)
|
||||||
@low_management_ward
|
@low_management_ward
|
||||||
async def cmd_configure_general(self, ctx: LionContext,
|
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
|
t = self.bot.translator.t
|
||||||
|
|
||||||
# Typechecker guards because they don't understand the check ward
|
# Typechecker guards because they don't understand the check ward
|
||||||
@@ -149,25 +111,25 @@ class GeneralSettingsCog(LionCog):
|
|||||||
'cmd:configure_general|success',
|
'cmd:configure_general|success',
|
||||||
"Settings Updated!"
|
"Settings Updated!"
|
||||||
)),
|
)),
|
||||||
description='\n'.join(
|
description='\n'.join(
|
||||||
f"{self.bot.config.emojis.tick} {line}" for line in results
|
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.
|
await ctx.reply(embed=success_embed)
|
||||||
else:
|
# TODO: Trigger configuration panel update if listening UI.
|
||||||
# Show general configuration panel UI
|
else:
|
||||||
# TODO Interactive UI
|
# Show general configuration panel UI
|
||||||
embed = discord.Embed(
|
# TODO Interactive UI
|
||||||
colour=discord.Colour.orange(),
|
embed = discord.Embed(
|
||||||
title=t(_p(
|
colour=discord.Colour.orange(),
|
||||||
'cmd:configure_general|panel|title',
|
title=t(_p(
|
||||||
"General Configuration Panel"
|
'cmd:configure_general|panel|title',
|
||||||
))
|
"General Configuration Panel"
|
||||||
)
|
))
|
||||||
embed.add_field(
|
)
|
||||||
**ctx.lguild.config.timezone.embed_field
|
embed.add_field(
|
||||||
)
|
**ctx.lguild.config.timezone.embed_field
|
||||||
await ctx.reply(embed=embed)
|
)
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
|
||||||
cmd_configure_general.autocomplete('timezone')(TimezoneSetting.parse_acmpl)
|
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
|
||||||
@@ -299,6 +299,20 @@ class Economy(LionCog):
|
|||||||
).set(
|
).set(
|
||||||
coins=set_to
|
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:
|
else:
|
||||||
if role:
|
if role:
|
||||||
if role.is_default():
|
if role.is_default():
|
||||||
@@ -360,6 +374,20 @@ class Economy(LionCog):
|
|||||||
amount=add,
|
amount=add,
|
||||||
new_amount=results[0]['coins']
|
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(
|
title = t(_np(
|
||||||
'cmd:economy_balance|embed:success|title',
|
'cmd:economy_balance|embed:success|title',
|
||||||
@@ -782,7 +810,20 @@ class Economy(LionCog):
|
|||||||
await ctx.alion.data.update(coins=(Member.coins - amount))
|
await ctx.alion.data.update(coins=(Member.coins - amount))
|
||||||
await target_lion.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")
|
await asyncio.create_task(wrapped(), name="wrapped-send")
|
||||||
|
|
||||||
# Message target
|
# Message target
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from discord import app_commands as appcmds
|
|||||||
from meta import LionCog, LionBot, LionContext
|
from meta import LionCog, LionBot, LionContext
|
||||||
from meta.logger import log_wrap
|
from meta.logger import log_wrap
|
||||||
from meta.sharding import THIS_SHARD
|
from meta.sharding import THIS_SHARD
|
||||||
|
from babel.translator import ctx_locale
|
||||||
from utils.lib import utc_now
|
from utils.lib import utc_now
|
||||||
|
|
||||||
from wards import low_management_ward, equippable_role, high_management_ward
|
from wards import low_management_ward, equippable_role, high_management_ward
|
||||||
@@ -109,6 +110,23 @@ class MemberAdminCog(LionCog):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._adding_roles.discard((member.guild.id, member.id))
|
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:
|
else:
|
||||||
# Returning member
|
# Returning member
|
||||||
|
|
||||||
@@ -181,6 +199,39 @@ class MemberAdminCog(LionCog):
|
|||||||
finally:
|
finally:
|
||||||
self._adding_roles.discard((member.guild.id, member.id))
|
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')
|
@LionCog.listener('on_raw_member_remove')
|
||||||
@log_wrap(action="Farewell")
|
@log_wrap(action="Farewell")
|
||||||
async def admin_member_farewell(self, payload: discord.RawMemberRemoveEvent):
|
async def admin_member_farewell(self, payload: discord.RawMemberRemoveEvent):
|
||||||
@@ -195,6 +246,7 @@ class MemberAdminCog(LionCog):
|
|||||||
await lion.data.update(last_left=utc_now())
|
await lion.data.update(last_left=utc_now())
|
||||||
|
|
||||||
# Save member roles
|
# Save member roles
|
||||||
|
roles = None
|
||||||
async with self.bot.db.connection() as conn:
|
async with self.bot.db.connection() as conn:
|
||||||
self.bot.db.conn = conn
|
self.bot.db.conn = conn
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
@@ -206,6 +258,7 @@ class MemberAdminCog(LionCog):
|
|||||||
print(type(payload.user))
|
print(type(payload.user))
|
||||||
if isinstance(payload.user, discord.Member) and payload.user.roles:
|
if isinstance(payload.user, discord.Member) and payload.user.roles:
|
||||||
member = payload.user
|
member = payload.user
|
||||||
|
roles = member.roles
|
||||||
await self.data.past_roles.insert_many(
|
await self.data.past_roles.insert_many(
|
||||||
('guildid', 'userid', 'roleid'),
|
('guildid', 'userid', 'roleid'),
|
||||||
*((guildid, userid, role.id) for role in member.roles)
|
*((guildid, userid, role.id) for role in member.roles)
|
||||||
@@ -213,7 +266,38 @@ class MemberAdminCog(LionCog):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Stored persisting roles for member <uid:{userid}> in <gid:{guildid}>."
|
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')
|
@LionCog.listener('on_guild_join')
|
||||||
async def admin_init_guild(self, guild: discord.Guild):
|
async def admin_init_guild(self, guild: discord.Guild):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import pytz
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ class Ticket:
|
|||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=title,
|
title=title,
|
||||||
description=data.content,
|
description=data.content,
|
||||||
timestamp=data.created_at,
|
timestamp=data.created_at.replace(tzinfo=pytz.utc),
|
||||||
colour=discord.Colour.orange()
|
colour=discord.Colour.orange()
|
||||||
)
|
)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class TimerOptions(SettingGroup):
|
|||||||
_allow_object = False
|
_allow_object = False
|
||||||
|
|
||||||
@classmethod
|
@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:
|
if value is not None:
|
||||||
# TODO: Check we either have or can create a webhook
|
# TODO: Check we either have or can create a webhook
|
||||||
# TODO: Check we can send messages, embeds, and files
|
# TODO: Check we can send messages, embeds, and files
|
||||||
|
|||||||
@@ -145,13 +145,11 @@ class TimerOptionsUI(MessageUI):
|
|||||||
value = selected.values[0] if selected.values else None
|
value = selected.values[0] if selected.values else None
|
||||||
setting = self.timer.config.get('notification_channel')
|
setting = self.timer.config.get('notification_channel')
|
||||||
|
|
||||||
if issue := await setting._check_value(self.timer.data.channelid, value):
|
await setting._check_value(self.timer.data.channelid, value)
|
||||||
await selection.edit_original_response(embed=error_embed(issue))
|
setting.value = value
|
||||||
else:
|
await setting.write()
|
||||||
setting.value = value
|
await self.timer.send_status()
|
||||||
await setting.write()
|
await self.refresh(thinking=selection)
|
||||||
await self.timer.send_status()
|
|
||||||
await self.refresh(thinking=selection)
|
|
||||||
|
|
||||||
async def refresh_notification_menu(self):
|
async def refresh_notification_menu(self):
|
||||||
self.notification_menu.placeholder = self.bot.translator.t(_p(
|
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
|
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
|
# Now update roles
|
||||||
new_last_roleid = last_roleid
|
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()]
|
to_rm = [role for role in to_rm if role.is_assignable()]
|
||||||
if to_rm:
|
if to_rm:
|
||||||
try:
|
try:
|
||||||
@@ -336,32 +341,68 @@ class RankCog(LionCog):
|
|||||||
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
||||||
)
|
)
|
||||||
new_last_roleid = None
|
new_last_roleid = None
|
||||||
except discord.HTTPException:
|
except discord.HTTPException as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
||||||
exc_info=True
|
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():
|
if to_add:
|
||||||
try:
|
if to_add.is_assignable():
|
||||||
await member.add_roles(
|
try:
|
||||||
to_add,
|
await member.add_roles(
|
||||||
reason="Rewarding Activity Rank",
|
to_add,
|
||||||
atomic=True
|
reason="Rewarding Activity Rank",
|
||||||
)
|
atomic=True
|
||||||
logger.info(
|
)
|
||||||
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
|
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:
|
last_roleid=to_add.id
|
||||||
logger.warning(
|
except discord.HTTPException as e:
|
||||||
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
|
logger.warning(
|
||||||
exc_info=True
|
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:
|
if new_last_roleid != last_roleid:
|
||||||
await session_rank.rankrow.update(last_roleid=new_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")
|
@log_wrap(action="Update Rank")
|
||||||
async def update_rank(self, session_rank):
|
async def update_rank(self, session_rank):
|
||||||
# Identify target rank
|
# Identify target rank
|
||||||
@@ -390,6 +431,11 @@ class RankCog(LionCog):
|
|||||||
if member is None:
|
if member is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
t = self.bot.translator.t
|
||||||
|
log_errors: list[str] = []
|
||||||
|
log_added = None
|
||||||
|
log_removed = None
|
||||||
|
|
||||||
last_roleid = session_rank.rankrow.last_roleid
|
last_roleid = session_rank.rankrow.last_roleid
|
||||||
|
|
||||||
# Update ranks
|
# Update ranks
|
||||||
@@ -409,7 +455,6 @@ class RankCog(LionCog):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Now update roles
|
# Now update roles
|
||||||
# TODO: Event log here, including errors
|
|
||||||
to_rm = [role for role in to_rm if role.is_assignable()]
|
to_rm = [role for role in to_rm if role.is_assignable()]
|
||||||
if to_rm:
|
if to_rm:
|
||||||
try:
|
try:
|
||||||
@@ -423,28 +468,50 @@ class RankCog(LionCog):
|
|||||||
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
f"Removed old rank roles from <uid:{userid}> in <gid:{guildid}>: {roleids}"
|
||||||
)
|
)
|
||||||
last_roleid = None
|
last_roleid = None
|
||||||
except discord.HTTPException:
|
except discord.HTTPException as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
f"Unexpected error removing old rank roles from <uid:{member.id}> in <gid:{guild.id}>: {to_rm}",
|
||||||
exc_info=True
|
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():
|
if to_add:
|
||||||
try:
|
if to_add.is_assignable():
|
||||||
await member.add_roles(
|
try:
|
||||||
to_add,
|
await member.add_roles(
|
||||||
reason="Rewarding Activity Rank",
|
to_add,
|
||||||
atomic=True
|
reason="Rewarding Activity Rank",
|
||||||
)
|
atomic=True
|
||||||
logger.info(
|
)
|
||||||
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
|
logger.info(
|
||||||
)
|
f"Rewarded rank role <rid:{to_add.id}> to <uid:{userid}> in <gid:{guildid}>."
|
||||||
last_roleid=to_add.id
|
)
|
||||||
except discord.HTTPException:
|
last_roleid=to_add.id
|
||||||
logger.warning(
|
except discord.HTTPException as e:
|
||||||
f"Unexpected error giving <uid:{userid}> in <gid:{guildid}> their rank role <rid:{to_add.id}>",
|
logger.warning(
|
||||||
exc_info=True
|
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
|
# Update MemberRank row
|
||||||
column = {
|
column = {
|
||||||
@@ -473,7 +540,29 @@ class RankCog(LionCog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send notification
|
# 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):
|
async def _notify_rank_update(self, guildid, userid, new_rank):
|
||||||
"""
|
"""
|
||||||
@@ -516,11 +605,7 @@ class RankCog(LionCog):
|
|||||||
text = member.mention
|
text = member.mention
|
||||||
|
|
||||||
# Post!
|
# Post!
|
||||||
try:
|
await destination.send(embed=embed, content=text)
|
||||||
await destination.send(embed=embed, content=text)
|
|
||||||
except discord.HTTPException:
|
|
||||||
# TODO: Logging, guild logging, invalidate channel if permissions are wrong
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_message_map(self,
|
def get_message_map(self,
|
||||||
rank_type: RankType,
|
rank_type: RankType,
|
||||||
@@ -777,6 +862,24 @@ class RankCog(LionCog):
|
|||||||
self.flush_guild_ranks(guild.id)
|
self.flush_guild_ranks(guild.id)
|
||||||
await ui.set_done()
|
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 ----------
|
# ---------- Commands ----------
|
||||||
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
|
@cmds.hybrid_command(name=_p('cmd:ranks', "ranks"))
|
||||||
async def ranks_cmd(self, ctx: LionContext):
|
async def ranks_cmd(self, ctx: LionContext):
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ from meta.logger import log_wrap
|
|||||||
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation
|
from meta.errors import ResponseTimedOut, UserInputError, UserCancelled, SafeCancellation
|
||||||
from meta.sharding import THIS_SHARD
|
from meta.sharding import THIS_SHARD
|
||||||
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
|
from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
|
||||||
from utils.lib import utc_now, error_embed
|
from utils.lib import utc_now, error_embed, jumpto
|
||||||
from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents
|
from utils.ui import Confirm, ChoicedEnum, Transformed, AButton, AsComponents
|
||||||
from utils.transformers import DurationTransformer
|
from utils.transformers import DurationTransformer
|
||||||
from utils.monitor import TaskMonitor
|
from utils.monitor import TaskMonitor
|
||||||
|
from babel.translator import ctx_locale
|
||||||
from constants import MAX_COINS
|
from constants import MAX_COINS
|
||||||
from data import NULL
|
from data import NULL
|
||||||
|
|
||||||
@@ -315,6 +316,11 @@ class RoleMenuCog(LionCog):
|
|||||||
menu = await self.data.RoleMenu.fetch(equip_row.menuid)
|
menu = await self.data.RoleMenu.fetch(equip_row.menuid)
|
||||||
guild = self.bot.get_guild(menu.guildid)
|
guild = self.bot.get_guild(menu.guildid)
|
||||||
if guild is not None:
|
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)
|
role = guild.get_role(equip_row.roleid)
|
||||||
if role is not None:
|
if role is not None:
|
||||||
lion = await self.bot.core.lions.fetch_member(guild.id, equip_row.userid)
|
lion = await self.bot.core.lions.fetch_member(guild.id, equip_row.userid)
|
||||||
@@ -322,6 +328,10 @@ class RoleMenuCog(LionCog):
|
|||||||
if (member := lion.member):
|
if (member := lion.member):
|
||||||
if role in member.roles:
|
if role in member.roles:
|
||||||
logger.error(f"Expired {equipid}, but the member still has the role!")
|
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:
|
else:
|
||||||
logger.info(f"Expired {equipid}, and successfully removed the role from the member!")
|
logger.info(f"Expired {equipid}, and successfully removed the role from the member!")
|
||||||
else:
|
else:
|
||||||
@@ -329,9 +339,56 @@ class RoleMenuCog(LionCog):
|
|||||||
f"Expired {equipid} for non-existent member {equip_row.userid}. "
|
f"Expired {equipid} for non-existent member {equip_row.userid}. "
|
||||||
"Removed from persistent roles."
|
"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:
|
else:
|
||||||
logger.info(f"Could not expire {equipid} because the role was not found.")
|
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()
|
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)
|
await equip_row.update(removed_at=now)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Could not expire {equipid} because the guild was not found.")
|
logger.info(f"Could not expire {equipid} because the guild was not found.")
|
||||||
|
|||||||
@@ -609,7 +609,24 @@ class RoleMenu:
|
|||||||
if remove_line:
|
if remove_line:
|
||||||
embed.description = '\n'.join((remove_line, embed.description))
|
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
|
return embed
|
||||||
|
|
||||||
async def _handle_negative(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.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',
|
'rolemenu|deselect|success:norefund|desc',
|
||||||
"You have unequipped **{role}**."
|
"You have unequipped **{role}**."
|
||||||
)).format(role=role.name)
|
)).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
|
return embed
|
||||||
|
|
||||||
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
|
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
|
||||||
lock_key = ('rmenu', member.id, member.guild.id)
|
lock_key = ('rmenu', member.id, member.guild.id)
|
||||||
async with self.bot.idlock(lock_key):
|
async with self.bot.idlock(lock_key):
|
||||||
# TODO: Selection locking
|
|
||||||
mrole = self.rolemap.get(menuroleid, None)
|
mrole = self.rolemap.get(menuroleid, None)
|
||||||
if mrole is None:
|
if mrole is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ class RoomCog(LionCog):
|
|||||||
async def _destroy_channel_room(self, channel: discord.abc.GuildChannel):
|
async def _destroy_channel_room(self, channel: discord.abc.GuildChannel):
|
||||||
room = self._room_cache[channel.guild.id].get(channel.id, None)
|
room = self._room_cache[channel.guild.id].get(channel.id, None)
|
||||||
if room is not 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")
|
await room.destroy(reason="Underlying Channel Deleted")
|
||||||
|
|
||||||
# Setting event handlers
|
# Setting event handlers
|
||||||
@@ -228,6 +242,7 @@ class RoomCog(LionCog):
|
|||||||
"""
|
"""
|
||||||
Create a new private room.
|
Create a new private room.
|
||||||
"""
|
"""
|
||||||
|
t = self.bot.translator.t
|
||||||
lguild = await self.bot.core.lions.fetch_guild(guild.id)
|
lguild = await self.bot.core.lions.fetch_guild(guild.id)
|
||||||
|
|
||||||
# TODO: Consider extending invites to members rather than giving them immediate access
|
# TODO: Consider extending invites to members rather than giving them immediate access
|
||||||
@@ -247,12 +262,31 @@ class RoomCog(LionCog):
|
|||||||
overwrites[member] = member_overwrite
|
overwrites[member] = member_overwrite
|
||||||
|
|
||||||
# Create channel
|
# Create channel
|
||||||
channel = await guild.create_voice_channel(
|
try:
|
||||||
name=name,
|
channel = await guild.create_voice_channel(
|
||||||
reason=f"Creating Private Room for {owner.id}",
|
name=name,
|
||||||
category=lguild.config.get(RoomSettings.Category.setting_id).value,
|
reason=t(_p(
|
||||||
overwrites=overwrites
|
'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:
|
try:
|
||||||
# Create Room
|
# Create Room
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
@@ -289,6 +323,17 @@ class RoomCog(LionCog):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"New private room created: {room.data!r}"
|
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
|
return room
|
||||||
|
|
||||||
@@ -490,7 +535,7 @@ class RoomCog(LionCog):
|
|||||||
await ui.send(room.channel)
|
await ui.send(room.channel)
|
||||||
|
|
||||||
@log_wrap(action='create_room')
|
@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
|
t = self.bot.translator.t
|
||||||
# TODO: Rollback the channel create if this fails
|
# TODO: Rollback the channel create if this fails
|
||||||
async with self.bot.db.connection() as conn:
|
async with self.bot.db.connection() as conn:
|
||||||
@@ -545,7 +590,6 @@ class RoomCog(LionCog):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
|
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
|
||||||
return
|
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
embed=error_embed(
|
embed=error_embed(
|
||||||
@@ -558,7 +602,6 @@ class RoomCog(LionCog):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
|
await ctx.alion.data.update(coins=CoreData.Member.coins + required)
|
||||||
return
|
|
||||||
|
|
||||||
@room_group.command(
|
@room_group.command(
|
||||||
name=_p('cmd:room_status', "status"),
|
name=_p('cmd:room_status', "status"),
|
||||||
|
|||||||
@@ -71,6 +71,48 @@ class Room:
|
|||||||
def deleted(self):
|
def deleted(self):
|
||||||
return bool(self.data.deleted_at)
|
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):
|
async def notify_deposit(self, member: discord.Member, amount: int):
|
||||||
# Assumes locale is set correctly
|
# Assumes locale is set correctly
|
||||||
t = self.bot.translator.t
|
t = self.bot.translator.t
|
||||||
@@ -108,6 +150,20 @@ class Room:
|
|||||||
"Welcome {members}"
|
"Welcome {members}"
|
||||||
)).format(members=', '.join(f"<@{mid}>" for mid in memberids))
|
)).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:
|
if self.channel:
|
||||||
try:
|
try:
|
||||||
await self.channel.send(embed=notification)
|
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))
|
await member_data.table.delete_where(channelid=self.data.channelid, userid=list(memberids))
|
||||||
self.members = list(set(self.members).difference(memberids))
|
self.members = list(set(self.members).difference(memberids))
|
||||||
# No need to notify for removal
|
# 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:
|
if self.channel:
|
||||||
guild = self.channel.guild
|
guild = self.channel.guild
|
||||||
members = [guild.get_member(memberid) for memberid in memberids]
|
members = [guild.get_member(memberid) for memberid in memberids]
|
||||||
@@ -255,6 +326,19 @@ class Room:
|
|||||||
await owner.send(embed=embed)
|
await owner.send(embed=embed)
|
||||||
except discord.HTTPException:
|
except discord.HTTPException:
|
||||||
pass
|
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')
|
await self.destroy(reason='Room Expired')
|
||||||
elif self.channel:
|
elif self.channel:
|
||||||
# Notify channel
|
# Notify channel
|
||||||
@@ -274,6 +358,19 @@ class Room:
|
|||||||
else:
|
else:
|
||||||
# No channel means room was deleted
|
# No channel means room was deleted
|
||||||
# Just cleanup quietly
|
# 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')
|
await self.destroy(reason='Channel Missing')
|
||||||
|
|
||||||
@log_wrap(action="Destroy Room")
|
@log_wrap(action="Destroy Room")
|
||||||
|
|||||||
@@ -296,6 +296,23 @@ class ColourShop(Shop):
|
|||||||
# TODO: Event log
|
# TODO: Event log
|
||||||
pass
|
pass
|
||||||
await self.data.MemberInventory.table.delete_where(inventoryid=owned.data.inventoryid)
|
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
|
# Purchase complete, update the shop and customer
|
||||||
await self.refresh()
|
await self.refresh()
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class VideoTicket(Ticket):
|
|||||||
**kwargs
|
**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)
|
lguild = await bot.core.lions.fetch_guild(member.guild.id, guild=member.guild)
|
||||||
new_ticket = cls(lguild, ticket_data)
|
new_ticket = cls(lguild, ticket_data)
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,12 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
|||||||
data = await cls._parse_string(parent_id, userstr, **kwargs)
|
data = await cls._parse_string(parent_id, userstr, **kwargs)
|
||||||
return cls(parent_id, data, **kwargs)
|
return cls(parent_id, data, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_value(cls, parent_id, value, **kwargs):
|
||||||
|
await cls._check_value(parent_id, value, **kwargs)
|
||||||
|
data = cls._data_from_value(parent_id, value, **kwargs)
|
||||||
|
return cls(parent_id, data, **kwargs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _parse_string(cls, parent_id, string: str, **kwargs) -> Optional[SettingData]:
|
async def _parse_string(cls, parent_id, string: str, **kwargs) -> Optional[SettingData]:
|
||||||
"""
|
"""
|
||||||
@@ -471,15 +477,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _check_value(cls, parent_id, value, **kwargs) -> Optional[str]:
|
async def _check_value(cls, parent_id, value, **kwargs):
|
||||||
"""
|
"""
|
||||||
Check the provided value is valid.
|
Check the provided value is valid.
|
||||||
|
|
||||||
Many setting update methods now provide Discord objects instead of raw data or user strings.
|
Many setting update methods now provide Discord objects instead of raw data or user strings.
|
||||||
This method may be used for value-checking such a value.
|
This method may be used for value-checking such a value.
|
||||||
|
|
||||||
Returns `None` if there are no issues, otherwise an error message.
|
Raises UserInputError if the value fails validation.
|
||||||
Subclasses should override this to implement a value checker.
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -505,10 +505,27 @@ class VoiceTrackerCog(LionCog):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f"Scheduling voice session for member `{member.name}' <uid:{member.id}> "
|
f"Scheduling voice session for member `{member.name}' <uid:{member.id}> "
|
||||||
f"in guild '{member.guild.name}' <gid: member.guild.id> "
|
f"in guild '{member.guild.name}' <gid: member.guild.id> "
|
||||||
f"in channel '{achannel}' <cid: {after.channel.id}>. "
|
f"in channel '{achannel}' <cid: {achannel.id}>. "
|
||||||
f"Session will start at {start}, expire at {expiry}, and confirm in {delay}."
|
f"Session will start at {start}, expire at {expiry}, and confirm in {delay}."
|
||||||
)
|
)
|
||||||
await session.schedule_start(delay, start, expiry, astate, hourly_rate)
|
await session.schedule_start(delay, start, expiry, astate, hourly_rate)
|
||||||
|
|
||||||
|
t = self.bot.translator.t
|
||||||
|
lguild = await self.bot.core.lions.fetch_guild(member.guild.id)
|
||||||
|
lguild.log_event(
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_start|title',
|
||||||
|
"Member Joined Tracked Voice Channel"
|
||||||
|
)),
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_start|desc',
|
||||||
|
"{member} joined {channel}."
|
||||||
|
)).format(
|
||||||
|
member=member.mention, channel=achannel.mention,
|
||||||
|
),
|
||||||
|
start=discord.utils.format_dt(start, 'F'),
|
||||||
|
expiry=discord.utils.format_dt(expiry, 'R'),
|
||||||
|
)
|
||||||
elif session.activity:
|
elif session.activity:
|
||||||
# If the channelid did not change, the live state must have
|
# If the channelid did not change, the live state must have
|
||||||
# Recalculate the economy rate, and update the session
|
# Recalculate the economy rate, and update the session
|
||||||
@@ -584,7 +601,8 @@ class VoiceTrackerCog(LionCog):
|
|||||||
start_time = now
|
start_time = now
|
||||||
delay = 20
|
delay = 20
|
||||||
|
|
||||||
expiry = start_time + dt.timedelta(seconds=cap)
|
remaining = cap - studied_today
|
||||||
|
expiry = start_time + dt.timedelta(seconds=remaining)
|
||||||
if expiry > tomorrow:
|
if expiry > tomorrow:
|
||||||
expiry = tomorrow + dt.timedelta(seconds=cap)
|
expiry = tomorrow + dt.timedelta(seconds=cap)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from data import RowModel, Registry, Table
|
|||||||
from data.columns import Integer, String, Timestamp, Bool
|
from data.columns import Integer, String, Timestamp, Bool
|
||||||
|
|
||||||
from core.data import CoreData
|
from core.data import CoreData
|
||||||
|
from utils.lib import utc_now
|
||||||
|
|
||||||
|
|
||||||
class VoiceTrackerData(Registry):
|
class VoiceTrackerData(Registry):
|
||||||
@@ -113,6 +114,11 @@ class VoiceTrackerData(Registry):
|
|||||||
live_video = Bool()
|
live_video = Bool()
|
||||||
hourly_coins = Integer()
|
hourly_coins = Integer()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _total_coins_earned(self):
|
||||||
|
since = (utc_now() - self.last_update).total_seconds() / 3600
|
||||||
|
return self.coins_earned + since * self.hourly_coins
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@log_wrap(action='close_voice_session')
|
@log_wrap(action='close_voice_session')
|
||||||
async def close_study_session_at(cls, guildid: int, userid: int, _at: dt.datetime) -> int:
|
async def close_study_session_at(cls, guildid: int, userid: int, _at: dt.datetime) -> int:
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from meta import LionBot
|
|||||||
from data import WeakCache
|
from data import WeakCache
|
||||||
from .data import VoiceTrackerData
|
from .data import VoiceTrackerData
|
||||||
|
|
||||||
from . import logger
|
from . import logger, babel
|
||||||
|
|
||||||
|
_p = babel._p
|
||||||
|
|
||||||
|
|
||||||
class TrackedVoiceState:
|
class TrackedVoiceState:
|
||||||
@@ -243,20 +245,6 @@ class VoiceSession:
|
|||||||
delay = (expire_time - utc_now()).total_seconds()
|
delay = (expire_time - utc_now()).total_seconds()
|
||||||
self.expiry_task = asyncio.create_task(self._expire_after(delay))
|
self.expiry_task = asyncio.create_task(self._expire_after(delay))
|
||||||
|
|
||||||
async def _expire_after(self, delay: int):
|
|
||||||
"""
|
|
||||||
Expire a session which has exceeded the daily voice cap.
|
|
||||||
"""
|
|
||||||
# TODO: Logging, and guild logging, and user notification (?)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
logger.info(
|
|
||||||
f"Expiring voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
|
|
||||||
f"and channel <cid:{self.state.channelid}>."
|
|
||||||
)
|
|
||||||
# TODO: Would be better not to close the session and wipe the state
|
|
||||||
# Instead start a new PENDING session.
|
|
||||||
await self.close()
|
|
||||||
|
|
||||||
async def update(self, new_state: Optional[TrackedVoiceState] = None, new_rate: Optional[int] = None):
|
async def update(self, new_state: Optional[TrackedVoiceState] = None, new_rate: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
Update the session state with the provided voice state or hourly rate.
|
Update the session state with the provided voice state or hourly rate.
|
||||||
@@ -282,26 +270,95 @@ class VoiceSession:
|
|||||||
rate=self.hourly_rate
|
rate=self.hourly_rate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _expire_after(self, delay: int):
|
||||||
|
"""
|
||||||
|
Expire a session which has exceeded the daily voice cap.
|
||||||
|
"""
|
||||||
|
# TODO: Logging, and guild logging, and user notification (?)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
logger.info(
|
||||||
|
f"Expiring voice session for member <uid:{self.userid}> in guild <gid:{self.guildid}> "
|
||||||
|
f"and channel <cid:{self.state.channelid}>."
|
||||||
|
)
|
||||||
|
async with self.lock:
|
||||||
|
await self._close()
|
||||||
|
|
||||||
|
if self.activity:
|
||||||
|
t = self.bot.translator.t
|
||||||
|
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||||
|
if self.activity is SessionState.ONGOING and self.data is not None:
|
||||||
|
lguild.log_event(
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_expired|title',
|
||||||
|
"Member Voice Session Expired"
|
||||||
|
)),
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_expired|desc',
|
||||||
|
"{member}'s voice session in {channel} expired "
|
||||||
|
"because they reached the daily voice cap."
|
||||||
|
)).format(
|
||||||
|
member=f"<@{self.userid}>",
|
||||||
|
channel=f"<#{self.state.channelid}>",
|
||||||
|
),
|
||||||
|
start=discord.utils.format_dt(self.data.start_time),
|
||||||
|
coins_earned=int(self.data._total_coins_earned),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.start_task is not None:
|
||||||
|
self.start_task.cancel()
|
||||||
|
self.start_task = None
|
||||||
|
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
cog = self.bot.get_cog('VoiceTrackerCog')
|
||||||
|
delay, start, expiry = await cog._session_boundaries_for(self.guildid, self.userid)
|
||||||
|
hourly_rate = await cog._calculate_rate(self.guildid, self.userid, self.state)
|
||||||
|
|
||||||
|
self.hourly_rate = hourly_rate
|
||||||
|
self._start_time = start
|
||||||
|
|
||||||
|
self.start_task = asyncio.create_task(self._start_after(delay, start))
|
||||||
|
self.schedule_expiry(expiry)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""
|
"""
|
||||||
Close the session, or cancel the pending session. Idempotent.
|
Close the session, or cancel the pending session. Idempotent.
|
||||||
"""
|
"""
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
if self.activity is SessionState.ONGOING:
|
await self._close()
|
||||||
# End the ongoing session
|
if self.activity:
|
||||||
now = utc_now()
|
t = self.bot.translator.t
|
||||||
await self.data.close_study_session_at(self.guildid, self.userid, now)
|
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||||
|
if self.activity is SessionState.ONGOING and self.data is not None:
|
||||||
# TODO: Something a bit saner/safer.. dispatch the finished session instead?
|
lguild.log_event(
|
||||||
self.bot.dispatch('voice_session_end', self.data, now)
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_closed|title',
|
||||||
# Rank update
|
"Member Voice Session Ended"
|
||||||
# TODO: Change to broadcasted event?
|
)),
|
||||||
rank_cog = self.bot.get_cog('RankCog')
|
t(_p(
|
||||||
if rank_cog is not None:
|
'eventlog|event:voice_session_closed|desc',
|
||||||
asyncio.create_task(rank_cog.on_voice_session_complete(
|
"{member} completed their voice session in {channel}."
|
||||||
(self.guildid, self.userid, int((utc_now() - self.data.start_time).total_seconds()), 0)
|
)).format(
|
||||||
))
|
member=f"<@{self.userid}>",
|
||||||
|
channel=f"<#{self.state.channelid}>",
|
||||||
|
),
|
||||||
|
start=discord.utils.format_dt(self.data.start_time),
|
||||||
|
coins_earned=int(self.data._total_coins_earned),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lguild.log_event(
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_cancelled|title',
|
||||||
|
"Member Voice Session Cancelled"
|
||||||
|
)),
|
||||||
|
t(_p(
|
||||||
|
'eventlog|event:voice_session_cancelled|desc',
|
||||||
|
"{member} left {channel} before their voice session started."
|
||||||
|
)).format(
|
||||||
|
member=f"<@{self.userid}>",
|
||||||
|
channel=f"<#{self.state.channelid}>",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if self.start_task is not None:
|
if self.start_task is not None:
|
||||||
self.start_task.cancel()
|
self.start_task.cancel()
|
||||||
@@ -319,3 +376,20 @@ class VoiceSession:
|
|||||||
|
|
||||||
# Always release strong reference to session (to allow garbage collection)
|
# Always release strong reference to session (to allow garbage collection)
|
||||||
self._active_sessions_[self.guildid].pop(self.userid)
|
self._active_sessions_[self.guildid].pop(self.userid)
|
||||||
|
|
||||||
|
async def _close(self):
|
||||||
|
if self.activity is SessionState.ONGOING:
|
||||||
|
# End the ongoing session
|
||||||
|
now = utc_now()
|
||||||
|
await self.data.close_study_session_at(self.guildid, self.userid, now)
|
||||||
|
|
||||||
|
# TODO: Something a bit saner/safer.. dispatch the finished session instead?
|
||||||
|
self.bot.dispatch('voice_session_end', self.data, now)
|
||||||
|
|
||||||
|
# Rank update
|
||||||
|
# TODO: Change to broadcasted event?
|
||||||
|
rank_cog = self.bot.get_cog('RankCog')
|
||||||
|
if rank_cog is not None:
|
||||||
|
asyncio.create_task(rank_cog.on_voice_session_complete(
|
||||||
|
(self.guildid, self.userid, int((utc_now() - self.data.start_time).total_seconds()), 0)
|
||||||
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user