Merge pull request #55 from StudyLions/rewrite

This commit is contained in:
Interitio
2023-10-01 15:59:33 +03:00
committed by GitHub
34 changed files with 1289 additions and 332 deletions

9
.gitignore vendored
View File

@@ -1,5 +1,14 @@
src/modules/test/* src/modules/test/*
pending-rewrite/
logs/*
notes/*
tmp/*
output/*
locales/domains
.idea/*
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@@ -1,7 +1,7 @@
aiohttp==3.7.4.post0 aiohttp==3.7.4.post0
cachetools==4.2.2 cachetools==4.2.2
configparser==5.0.2 configparser==5.0.2
discord.py discord.py [voice]
iso8601==0.1.16 iso8601==0.1.16
psycopg[pool] psycopg[pool]
pytz==2021.1 pytz==2021.1

View File

@@ -145,7 +145,7 @@ class BabelCog(LionCog):
t(_p( t(_p(
'cmd:configure_language|error', 'cmd:configure_language|error',
"You cannot enable `{force_setting}` without having a configured language!" "You cannot enable `{force_setting}` without having a configured language!"
)).format(force_setting=t(LocaleSettings.ForceLocale.display_name)) )).format(force_setting=t(LocaleSettings.ForceLocale._display_name))
) )
# TODO: Really need simultaneous model writes, or batched writes # TODO: Really need simultaneous model writes, or batched writes
lines = [] lines = []

View File

@@ -1,3 +1,4 @@
from typing import Optional
from settings import ModelData from settings import ModelData
from settings.setting_types import StringSetting, BoolSetting from settings.setting_types import StringSetting, BoolSetting
@@ -23,11 +24,11 @@ class LocaleSetting(StringSetting):
"Enter a supported language (e.g. 'en-GB')." "Enter a supported language (e.g. 'en-GB')."
) )
def _desc_table(self) -> list[str]: def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
translator = ctx_translator.get() translator = ctx_translator.get()
t = translator.t t = translator.t
lines = super()._desc_table() lines = super()._desc_table(show_value=show_value)
lines.append(( lines.append((
t(_p( t(_p(
'settype:locale|summary_table|field:supported|key', 'settype:locale|summary_table|field:supported|key',

View File

@@ -11,7 +11,7 @@ from discord.enums import Locale
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SOURCE_LOCALE = 'en-GB' SOURCE_LOCALE = 'en_GB'
ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE) ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE)
ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore
@@ -71,6 +71,7 @@ class LeoBabel(Translator):
self.translators.clear() self.translators.clear()
def get_translator(self, locale, domain): def get_translator(self, locale, domain):
locale = locale.replace('-', '_') if locale else None
if locale == SOURCE_LOCALE: if locale == SOURCE_LOCALE:
translator = null translator = null
elif locale in self.supported_locales and domain in self.supported_domains: elif locale in self.supported_locales and domain in self.supported_domains:
@@ -93,7 +94,8 @@ class LeoBabel(Translator):
return lazystr._translate_with(translator) return lazystr._translate_with(translator)
async def translate(self, string: locale_str, locale: Locale, context): async def translate(self, string: locale_str, locale: Locale, context):
if locale.value in self.supported_locales: loc = locale.value.replace('-', '_')
if loc in self.supported_locales:
domain = string.extras.get('domain', None) domain = string.extras.get('domain', None)
if domain is None and isinstance(string, LazyStr): if domain is None and isinstance(string, LazyStr):
logger.debug( logger.debug(
@@ -101,7 +103,7 @@ class LeoBabel(Translator):
) )
return None return None
translator = self.get_translator(locale.value, domain) translator = self.get_translator(loc, domain)
if not isinstance(string, LazyStr): if not isinstance(string, LazyStr):
lazy = LazyStr(Method.GETTEXT, string.message) lazy = LazyStr(Method.GETTEXT, string.message)
else: else:

View File

@@ -70,7 +70,7 @@ async def main():
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with LionBot( async with LionBot(
command_prefix=commands.when_mentioned, command_prefix='!leo!',
intents=intents, intents=intents,
appname=appname, appname=appname,
shardname=shardname, shardname=shardname,

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

View File

@@ -209,6 +209,9 @@ class EconomyData(Registry, name='economy'):
] ]
# Execute refund transactions # Execute refund transactions
return await cls.execute_transactions(*records) return await cls.execute_transactions(*records)
else:
return []
class ShopTransaction(RowModel): class ShopTransaction(RowModel):
""" """

View File

@@ -55,6 +55,7 @@ class MemberAdminSettings(SettingGroup):
_model = CoreData.Guild _model = CoreData.Guild
_column = CoreData.Guild.greeting_channel.name _column = CoreData.Guild.greeting_channel.name
_allow_object = False
@property @property
def update_message(self) -> str: def update_message(self) -> str:
@@ -188,8 +189,8 @@ class MemberAdminSettings(SettingGroup):
self.value = editor_data self.value = editor_data
await self.write() await self.write()
def _desc_table(self) -> list[str]: def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
lines = super()._desc_table() lines = super()._desc_table(show_value=show_value)
t = ctx_translator.get().t t = ctx_translator.get().t
keydescs = [ keydescs = [
(key, t(value)) for key, value in self._subkey_desc.items() (key, t(value)) for key, value in self._subkey_desc.items()
@@ -313,8 +314,8 @@ class MemberAdminSettings(SettingGroup):
self.value = editor_data self.value = editor_data
await self.write() await self.write()
def _desc_table(self) -> list[str]: def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
lines = super()._desc_table() lines = super()._desc_table(show_value=show_value)
t = ctx_translator.get().t t = ctx_translator.get().t
keydescs = [ keydescs = [
(key, t(value)) for key, value in self._subkey_desc_returning.items() (key, t(value)) for key, value in self._subkey_desc_returning.items()

View File

@@ -12,6 +12,7 @@ from meta.logger import log_wrap
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 from utils.lib import utc_now
from utils.ratelimits import limit_concurrency
from wards import low_management_ward from wards import low_management_ward
@@ -48,16 +49,38 @@ class TimerCog(LionCog):
self.timer_options = TimerOptions() self.timer_options = TimerOptions()
self.ready = False self.ready = False
self.timers = defaultdict(dict) self.timers: dict[int, dict[int, Timer]] = defaultdict(dict)
async def _monitor(self): async def _monitor(self):
timers = [timer for tguild in self.timers.values() for timer in tguild.values()]
state = (
"<TimerState"
" loaded={loaded}"
" guilds={guilds}"
" members={members}"
" running={running}"
" launched={launched}"
" looping={looping}"
" locked={locked}"
" voice_locked={voice_locked}"
">"
)
data = dict(
loaded=len(timers),
guilds=len(set(timer.data.guildid for timer in timers)),
members=sum(len(timer.members) for timer in timers),
running=sum(1 for timer in timers if timer.running),
launched=sum(1 for timer in timers if timer._run_task and not timer._run_task.done()),
looping=sum(1 for timer in timers if timer._loop_task and not timer._loop_task.done()),
locked=sum(1 for timer in timers if timer._lock.locked()),
voice_locked=sum(1 for timer in timers if timer._voice_update_lock.locked()),
)
if not self.ready: if not self.ready:
level = StatusLevel.STARTING level = StatusLevel.STARTING
info = "(STARTING) Not ready. {timers} timers loaded." info = f"(STARTING) Not ready. {state}"
else: else:
level = StatusLevel.OKAY level = StatusLevel.OKAY
info = "(OK) {timers} timers loaded." info = f"(OK) Ready. {state}"
data = dict(timers=len(self.timers))
return ComponentStatus(level, info, info, data) return ComponentStatus(level, info, info, data)
async def cog_load(self): async def cog_load(self):
@@ -79,15 +102,12 @@ class TimerCog(LionCog):
Clears caches and stops run-tasks for each active timer. Clears caches and stops run-tasks for each active timer.
Does not exist until all timers have completed background tasks. Does not exist until all timers have completed background tasks.
""" """
timers = (timer for tguild in self.timers.values() for timer in tguild.values()) timers = [timer for tguild in self.timers.values() for timer in tguild.values()]
try:
await asyncio.gather(*(timer.unload() for timer in timers))
except Exception:
logger.exception(
"Exception encountered while unloading `TimerCog`"
)
self.timers.clear() self.timers.clear()
if timers:
await self._unload_timers(timers)
async def cog_check(self, ctx: LionContext): async def cog_check(self, ctx: LionContext):
if not self.ready: if not self.ready:
raise CheckFailure( raise CheckFailure(
@@ -101,6 +121,20 @@ class TimerCog(LionCog):
else: else:
return True return True
@log_wrap(action='Unload Timers')
async def _unload_timers(self, timers: list[Timer]):
"""
Unload all active timers.
"""
tasks = [asyncio.create_task(timer.unload()) for timer in timers]
for timer, task in zip(timers, tasks):
try:
await task
except Exception:
logger.exception(
f"Unexpected exception while unloading timer {timer!r}"
)
async def _load_timers(self, timer_data: list[TimerData.Timer]): async def _load_timers(self, timer_data: list[TimerData.Timer]):
""" """
Factored method to load a list of timers from data rows. Factored method to load a list of timers from data rows.
@@ -108,6 +142,7 @@ class TimerCog(LionCog):
guildids = set() guildids = set()
to_delete = [] to_delete = []
to_create = [] to_create = []
to_unload = []
for row in timer_data: for row in timer_data:
channel = self.bot.get_channel(row.channelid) channel = self.bot.get_channel(row.channelid)
if not channel: if not channel:
@@ -115,6 +150,12 @@ class TimerCog(LionCog):
else: else:
guildids.add(row.guildid) guildids.add(row.guildid)
to_create.append(row) to_create.append(row)
if row.guildid in self.timers:
if row.channelid in self.timers[row.guildid]:
to_unload.append(self.timers[row.guildid].pop(row.channelid))
if to_unload:
await self._unload_timers(to_unload)
if guildids: if guildids:
lguilds = await self.bot.core.lions.fetch_guilds(*guildids) lguilds = await self.bot.core.lions.fetch_guilds(*guildids)
@@ -145,37 +186,57 @@ class TimerCog(LionCog):
# Re-launch and update running timers # Re-launch and update running timers
for timer in to_launch: for timer in to_launch:
timer.launch() timer.launch()
tasks = [
asyncio.create_task(timer.update_status_card()) for timer in to_launch coros = [timer.update_status_card() for timer in to_launch]
] if coros:
if tasks: i = 0
try: async for task in limit_concurrency(coros, 10):
await asyncio.gather(*tasks) try:
except Exception: await task
logger.exception( except discord.HTTPException:
"Exception occurred updating timer status for running timers." timer = to_launch[i]
) logger.warning(
f"Unhandled discord exception while updating timer status for {timer!r}",
exc_info=True
)
except Exception:
timer = to_launch[i]
logger.exception(
f"Unexpected exception while updating timer status for {timer!r}",
exc_info=True
)
i += 1
logger.info( logger.info(
f"Updated and launched {len(to_launch)} running timers." f"Updated and launched {len(to_launch)} running timers."
) )
# Update stopped timers # Update stopped timers
tasks = [ coros = [timer.update_status_card(render=False) for timer in to_update]
asyncio.create_task(timer.update_status_card()) for timer in to_update if coros:
] i = 0
if tasks: async for task in limit_concurrency(coros, 10):
try: try:
await asyncio.gather(*tasks) await task
except Exception: except discord.HTTPException:
logger.exception( timer = to_update[i]
"Exception occurred updating timer status for stopped timers." logger.warning(
) f"Unhandled discord exception while updating timer status for {timer!r}",
exc_info=True
)
except Exception:
timer = to_update[i]
logger.exception(
f"Unexpected exception while updating timer status for {timer!r}",
exc_info=True
)
i += 1
logger.info( logger.info(
f"Updated {len(to_update)} stopped timers." f"Updated {len(to_update)} stopped timers."
) )
# Update timer registry # Update timer registry
self.timers.update(timer_reg) for gid, gtimers in timer_reg.items():
self.timers[gid].update(gtimers)
@LionCog.listener('on_ready') @LionCog.listener('on_ready')
@log_wrap(action='Init Timers') @log_wrap(action='Init Timers')
@@ -185,10 +246,14 @@ class TimerCog(LionCog):
""" """
self.ready = False self.ready = False
self.timers = defaultdict(dict) self.timers = defaultdict(dict)
if self.timers:
timers = [timer for tguild in self.timers.values() for timer in tguild.values()]
await self._unload_timers(timers)
self.timers.clear()
# Fetch timers in guilds on this shard # Fetch timers in guilds on this shard
# TODO: Join with guilds and filter by guilds we are still in guildids = [guild.id for guild in self.bot.guilds]
timer_data = await self.data.Timer.fetch_where(THIS_SHARD) timer_data = await self.data.Timer.fetch_where(guildid=guildids)
await self._load_timers(timer_data) await self._load_timers(timer_data)
# Ready to handle events # Ready to handle events

View File

@@ -7,7 +7,7 @@ from datetime import timedelta, datetime
import discord import discord
from meta import LionBot from meta import LionBot
from meta.logger import log_wrap, log_context from meta.logger import log_wrap, log_context, set_logging_context
from utils.lib import MessageArgs, utc_now, replace_multiple from utils.lib import MessageArgs, utc_now, replace_multiple
from core.lion_guild import LionGuild from core.lion_guild import LionGuild
from core.data import CoreData from core.data import CoreData
@@ -61,7 +61,7 @@ class Timer:
log_context.set(f"tid: {self.data.channelid}") log_context.set(f"tid: {self.data.channelid}")
# State # State
self.last_seen: dict[int, int] = {} # memberid -> last seen timestamp self.last_seen: dict[int, datetime] = {} # memberid -> last seen timestamp
self.status_view: Optional[TimerStatusUI] = None # Current TimerStatusUI self.status_view: Optional[TimerStatusUI] = None # Current TimerStatusUI
self.last_status_message: Optional[discord.Message] = None # Last deliever notification message self.last_status_message: Optional[discord.Message] = None # Last deliever notification message
self._hook: Optional[CoreData.LionHook] = None # Cached notification webhook self._hook: Optional[CoreData.LionHook] = None # Cached notification webhook
@@ -384,7 +384,7 @@ class Timer:
tasks = [] tasks = []
after_tasks = [] after_tasks = []
# Submit channel name update request # Submit channel name update request
after_tasks.append(asyncio.create_task(self._update_channel_name())) after_tasks.append(asyncio.create_task(self._update_channel_name(), name='Update-name'))
if kick and (threshold := self.warning_threshold(from_stage)): if kick and (threshold := self.warning_threshold(from_stage)):
now = utc_now() now = utc_now()
@@ -397,38 +397,65 @@ class Timer:
elif last_seen < threshold: elif last_seen < threshold:
needs_kick.append(member) needs_kick.append(member)
for member in needs_kick: t = self.bot.translator.t
tasks.append(member.edit(voice_channel=None)) if self.channel and self.channel.permissions_for(self.channel.guild.me).move_members:
for member in needs_kick:
tasks.append(
asyncio.create_task(
member.edit(
voice_channel=None,
reason=t(_p(
'timer|disconnect|audit_reason',
"Disconnecting inactive member from timer."
), locale=self.locale.value)
),
name="Disconnect-timer-member"
)
)
notify_hook = await self.get_notification_webhook() notify_hook = await self.get_notification_webhook()
if needs_kick and notify_hook: if needs_kick and notify_hook and self.channel:
t = self.bot.translator.t if self.channel.permissions_for(self.channel.guild.me).move_members:
kick_message = t(_np( kick_message = t(_np(
'timer|kicked_message', 'timer|kicked_message',
"{mentions} was removed from {channel} because they were inactive! " "{mentions} was removed from {channel} because they were inactive! "
"Remember to press {tick} to register your presence every stage.", "Remember to press {tick} to register your presence every stage.",
"{mentions} were removed from {channel} because they were inactive! " "{mentions} were removed from {channel} because they were inactive! "
"Remember to press {tick} to register your presence every stage.", "Remember to press {tick} to register your presence every stage.",
len(needs_kick) len(needs_kick)
), locale=self.locale.value).format( ), locale=self.locale.value).format(
channel=f"<#{self.data.channelid}>", channel=f"<#{self.data.channelid}>",
mentions=', '.join(member.mention for member in needs_kick), mentions=', '.join(member.mention for member in needs_kick),
tick=self.bot.config.emojis.tick tick=self.bot.config.emojis.tick
) )
tasks.append(notify_hook.send(kick_message)) else:
kick_message = t(_p(
'timer|kick_failed',
"**Warning!** Timer {channel} is configured to disconnect on inactivity, "
"but I lack the 'Move Members' permission to do this!"
), locale=self.locale.value).format(
channel=self.channel.mention
)
tasks.append(asyncio.create_task(notify_hook.send(kick_message), name='kick-message'))
if self.voice_alerts: if self.voice_alerts:
after_tasks.append(asyncio.create_task(self._voice_alert(to_stage))) after_tasks.append(asyncio.create_task(self._voice_alert(to_stage), name='voice-alert'))
if tasks: for task in tasks:
try: try:
await asyncio.gather(*tasks) await task
except discord.Forbidden:
logger.warning(
f"Unexpected forbidden during pre-task {task!r} for change stage in timer {self!r}"
)
except discord.HTTPException:
logger.warning(
f"Unexpected API error during pre-task {task!r} for change stage in timer {self!r}"
)
except Exception: except Exception:
logger.exception(f"Exception occurred during pre-tasks for change stage in timer {self!r}") logger.exception(f"Exception occurred during pre-task {task!r} for change stage in timer {self!r}")
print("Sending Status")
await self.send_status() await self.send_status()
print("Sent Status")
if after_tasks: if after_tasks:
try: try:
@@ -444,7 +471,7 @@ class Timer:
if not stage: if not stage:
return return
if not self.channel or not self.channel.permissions_for(self.guild.me).speak: if not self.guild or not self.channel or not self.channel.permissions_for(self.guild.me).speak:
return return
async with self.lguild.voice_lock: async with self.lguild.voice_lock:
@@ -480,15 +507,16 @@ class Timer:
# Quit when we finish playing or after 10 seconds, whichever comes first # Quit when we finish playing or after 10 seconds, whichever comes first
sleep_task = asyncio.create_task(asyncio.sleep(10)) sleep_task = asyncio.create_task(asyncio.sleep(10))
wait_task = asyncio.create_task(finished.wait()) wait_task = asyncio.create_task(finished.wait(), name='timer-voice-waiting')
_, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED) _, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED)
for task in pending: for task in pending:
task.cancel() task.cancel()
await self.guild.voice_client.disconnect(force=True) if self.guild and self.guild.voice_client:
await self.guild.voice_client.disconnect(force=True)
except Exception: except Exception:
logger.exception( logger.exception(
"Exception occurred while playing voice alert for timer {self!r}" f"Exception occurred while playing voice alert for timer {self!r}"
) )
def stageline(self, stage: Stage): def stageline(self, stage: Stage):
@@ -511,7 +539,7 @@ class Timer:
) )
return stageline return stageline
async def current_status(self, with_notify=True, with_warnings=True) -> MessageArgs: async def current_status(self, with_notify=True, with_warnings=True, render=True) -> MessageArgs:
""" """
Message arguments for the current timer status message. Message arguments for the current timer status message.
""" """
@@ -520,7 +548,7 @@ class Timer:
ctx_locale.set(self.locale.value) ctx_locale.set(self.locale.value)
stage = self.current_stage stage = self.current_stage
if self.running: if self.running and stage is not None:
stageline = self.stageline(stage) stageline = self.stageline(stage)
warningline = "" warningline = ""
needs_warning = [] needs_warning = []
@@ -530,7 +558,7 @@ class Timer:
last_seen = self.last_seen.get(member.id, None) last_seen = self.last_seen.get(member.id, None)
if last_seen is None: if last_seen is None:
last_seen = self.last_seen[member.id] = now last_seen = self.last_seen[member.id] = now
elif last_seen < threshold: elif threshold and last_seen < threshold:
needs_warning.append(member) needs_warning.append(member)
if needs_warning: if needs_warning:
warningline = t(_p( warningline = t(_p(
@@ -567,13 +595,16 @@ class Timer:
await ui.refresh() await ui.refresh()
card = await get_timer_card(self.bot, self, stage) rawargs = dict(content=content, view=ui)
try:
await card.render() if render:
file = card.as_file(f"pomodoro_{self.data.channelid}.png") try:
args = MessageArgs(content=content, file=file, view=ui) card = await get_timer_card(self.bot, self, stage)
except RenderingException: await card.render()
args = MessageArgs(content=content, view=ui) rawargs['file'] = card.as_file(f"pomodoro_{self.data.channelid}.png")
except RenderingException:
pass
args = MessageArgs(**rawargs)
return args return args
@@ -764,12 +795,16 @@ class Timer:
f"Timer <tid: {channelid}> deleted. Reason given: {reason!r}" f"Timer <tid: {channelid}> deleted. Reason given: {reason!r}"
) )
@log_wrap(action='Timer Loop') @log_wrap(isolate=True, stack=())
async def _runloop(self): async def _runloop(self):
""" """
Main loop which controls the Main loop which controls the
regular stage changes and status updates. regular stage changes and status updates.
""" """
set_logging_context(
action=f"TimerLoop {self.data.channelid}",
context=f"tid: {self.data.channelid}",
)
# Allow updating with 10 seconds of drift to the next stage change # Allow updating with 10 seconds of drift to the next stage change
drift = 10 drift = 10
@@ -785,6 +820,11 @@ class Timer:
self._state = current = self.current_stage self._state = current = self.current_stage
while True: while True:
if current is None:
logger.exception(
f"Closing timer loop because current state is None. Timer {self!r}"
)
break
to_next_stage = (current.end - utc_now()).total_seconds() to_next_stage = (current.end - utc_now()).total_seconds()
# TODO: Consider request rate and load # TODO: Consider request rate and load
@@ -812,12 +852,18 @@ class Timer:
if current.end < utc_now(): if current.end < utc_now():
self._state = self.current_stage self._state = self.current_stage
task = asyncio.create_task(self.notify_change_stage(current, self._state)) task = asyncio.create_task(
self.notify_change_stage(current, self._state),
name='notify-change-stage'
)
background_tasks.add(task) background_tasks.add(task)
task.add_done_callback(background_tasks.discard) task.add_done_callback(background_tasks.discard)
current = self._state current = self._state
elif self.members: elif self.members:
task = asyncio.create_task(self._update_channel_name()) task = asyncio.create_task(
self._update_channel_name(),
name='regular-channel-update'
)
background_tasks.add(task) background_tasks.add(task)
task.add_done_callback(background_tasks.discard) task.add_done_callback(background_tasks.discard)
task = asyncio.create_task(self.update_status_card()) task = asyncio.create_task(self.update_status_card())
@@ -825,7 +871,13 @@ class Timer:
task.add_done_callback(background_tasks.discard) task.add_done_callback(background_tasks.discard)
if background_tasks: if background_tasks:
await asyncio.gather(*background_tasks) try:
await asyncio.gather(*background_tasks)
except Exception:
logger.warning(
f"Unexpected error while finishing background tasks for timer {self!r}",
exc_info=True
)
def launch(self): def launch(self):
""" """

View File

@@ -269,6 +269,9 @@ class RankCog(LionCog):
Handle batch of completed message sessions. Handle batch of completed message sessions.
""" """
for guildid, userid, messages, guild_xp in session_data: for guildid, userid, messages, guild_xp in session_data:
if not self.bot.get_guild(guildid):
# Ignore guilds we have left
continue
lguild = await self.bot.core.lions.fetch_guild(guildid) lguild = await self.bot.core.lions.fetch_guild(guildid)
rank_type = lguild.config.get('rank_type').value rank_type = lguild.config.get('rank_type').value
if rank_type in (RankType.MESSAGE, RankType.XP): if rank_type in (RankType.MESSAGE, RankType.XP):
@@ -542,6 +545,9 @@ class RankCog(LionCog):
@log_wrap(action="Voice Rank Hook") @log_wrap(action="Voice Rank Hook")
async def on_voice_session_complete(self, *session_data): async def on_voice_session_complete(self, *session_data):
for guildid, userid, duration, guild_xp in session_data: for guildid, userid, duration, guild_xp in session_data:
if not self.bot.get_guild(guildid):
# Ignore guilds we have left
continue
lguild = await self.bot.core.lions.fetch_guild(guildid) lguild = await self.bot.core.lions.fetch_guild(guildid)
unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid) unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid)
unranked_roleids = set(unranked_role_setting.data) unranked_roleids = set(unranked_role_setting.data)

View File

@@ -126,6 +126,7 @@ async def rolemenu_ctxcmd(interaction: discord.Interaction, message: discord.Mes
else: else:
menu = await RoleMenu.fetch(self.bot, menuid) menu = await RoleMenu.fetch(self.bot, menuid)
menu._message = message menu._message = message
await menu.update_raw()
# Open the editor # Open the editor
editor = MenuEditor(self.bot, menu, callerid=interaction.user.id) editor = MenuEditor(self.bot, menu, callerid=interaction.user.id)
@@ -732,7 +733,7 @@ class RoleMenuCog(LionCog):
# Parse menu options if given # Parse menu options if given
name = name.strip() name = name.strip()
matching = await self.data.RoleMenu.fetch_where(name=name) matching = await self.data.RoleMenu.fetch_where(name=name, guildid=ctx.guild.id)
if matching: if matching:
raise UserInputError( raise UserInputError(
t(_p( t(_p(
@@ -895,6 +896,7 @@ class RoleMenuCog(LionCog):
)).format(name=name) )).format(name=name)
) )
await target.fetch_message() await target.fetch_message()
await target.update_raw()
# Parse provided options # Parse provided options
reposting = channel is not None reposting = channel is not None
@@ -971,7 +973,41 @@ class RoleMenuCog(LionCog):
) )
# TODO: Generate the custom message from the template if it doesn't exist # TODO: Generate the custom message from the template if it doesn't exist
# TODO: Pathway for setting menu style if menu_style is not None:
if not managed and not reposting:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:style|error:not_managed',
"Cannot change the style of a role menu attached to a message I did not send."
))
)
if menu_style is MenuType.REACTION:
# Check menu is suitable for moving to reactions
roles = target.roles
if len(roles) > 20:
raise UserInputError(
t(_p(
'cmd:rolemenu_edit|parse:style|error:too_many_reactions',
"Too many roles! Reaction role menus can have at most `20` roles."
))
)
emojis = [mrole.config.emoji.value for mrole in roles]
emojis = [emoji for emoji in emojis if emoji]
uniq = set(emojis)
if len(uniq) != len(roles):
raise UserInputError(
t(_p(
"cmd:rolemenu_edit|parse:style|error:incomplete_emojis",
"Cannot switch to the reaction role style! Every role needs a distinct emoji first."
))
)
update_args[self.data.RoleMenu.menutype.name] = menu_style
ack_lines.append(
t(_p(
'cmd:rolemenu_edit|parse:style|success',
"Updated role menu style."
))
)
if rawmessage is not None: if rawmessage is not None:
msg_config = target.config.rawmessage msg_config = target.config.rawmessage
@@ -1019,6 +1055,11 @@ class RoleMenuCog(LionCog):
)).format(channel=channel.mention, exception=e.text)) )).format(channel=channel.mention, exception=e.text))
else: else:
await target.update_message() await target.update_message()
if menu_style is not None:
try:
await target.update_reactons()
except SafeCancellation as e:
error_lines.append(e.msg)
# Ack the updates # Ack the updates
if ack_lines or error_lines: if ack_lines or error_lines:

View File

@@ -25,7 +25,7 @@ from . import logger, babel
if TYPE_CHECKING: if TYPE_CHECKING:
from .cog import RoleMenuCog from .cog import RoleMenuCog
_p = babel._p _p, _np = babel._p, babel._np
MISSING = object() MISSING = object()
@@ -192,6 +192,20 @@ class RoleMenu:
self._message = _message self._message = _message
return self._message return self._message
async def update_raw(self):
"""
Updates the saved raw message data for non-owned menus.
"""
message = await self.fetch_message()
if not self.managed and message is not None:
message_data = {}
message_data['content'] = message.content
if message.embeds:
message_data['embed'] = message.embeds[0].to_dict()
rawmessage = json.dumps(message_data)
if rawmessage != self.data.rawmessage:
await self.data.update(rawmessage=rawmessage)
def emoji_map(self): def emoji_map(self):
emoji_map = {} emoji_map = {}
for mrole in self.roles: for mrole in self.roles:
@@ -454,100 +468,252 @@ class RoleMenu:
if emojikey(emoji) not in menu_emojis: if emojikey(emoji) not in menu_emojis:
yield str(emoji) yield str(emoji)
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int): async def _handle_positive(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.Embed:
mrole = self.rolemap.get(menuroleid, None)
if mrole is None:
raise ValueError(f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN.")
guild = member.guild
t = self.bot.translator.t t = self.bot.translator.t
guild = member.guild
role = guild.get_role(mrole.data.roleid) role = guild.get_role(mrole.data.roleid)
if role is None: if not role:
# This role no longer exists, nothing we can do raise ValueError("Calling _handle_positive without a valid role.")
price = mrole.config.price.value
obtainable = self.config.obtainable.value
remove_line = ''
if obtainable is not None:
# Check shared roles
menu_roles = {mrole.data.roleid: mrole for mrole in self.roles}
common = [role for role in member.roles if role.id in menu_roles]
if len(common) >= obtainable:
swap = None
if len(common) == 1 and not self.config.sticky.value:
swap = menu_roles[common[0].id]
# Check if LC will be lost by exchanging the role
if (swap.config.price.value) > 0 and not self.config.refunds.value:
swap = None
if swap is not None:
# Do remove
try:
remove_embed = await self._handle_negative(lion, member, swap)
remove_line = remove_embed.description
except UserInputError:
# If we failed to remove for some reason, pretend we didn't try
swap = None
if swap is None:
error = t(_np(
'rolemenu|select|error:max_obtainable',
"You can own at most one role from this menu! You currently own:",
"You can own at most **{count}** roles from this menu! You currently own:",
obtainable
)).format(count=obtainable)
error = '\n'.join((error, *(role.mention for role in common)))
raise UserInputError(error)
if price:
# Check member balance
# TODO: More transaction safe (or rather check again after transaction)
await lion.data.refresh()
balance = lion.data.coins
if balance < price:
raise UserInputError(
t(_p(
'rolemenu|select|error:insufficient_funds',
"The role **{role}** costs {coin}**{cost}**,"
"but you only have {coin}**{balance}**!"
)).format(
role=role.name,
coin=self.bot.config.emojis.coin,
cost=price,
balance=balance,
)
)
try:
await member.add_roles(role)
except discord.Forbidden:
raise UserInputError( raise UserInputError(
t(_p( t(_p(
'rolemenu|error:role_gone', 'rolemenu|select|error:perms',
"This role no longer exists!" "I don't have enough permissions to give you this role!"
)) ))
) )
if role in member.roles: except discord.HTTPException:
# Member already has the role, deselection case. raise UserInputError(
if self.config.sticky.value: t(_p(
# Cannot deselect 'rolemenu|select|error:discord',
raise UserInputError( "An unknown error occurred while assigning your role! "
t(_p( "Please try again later."
'rolemenu|deselect|error:sticky',
"**{role}** is a sticky role, you cannot remove it with this menu!"
)).format(role=role.name)
)
# Remove the role
try:
await member.remove_roles(role)
except discord.Forbidden:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:perms',
"I don't have enough permissions to remove this role from you!"
))
)
except discord.HTTPException:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:discord',
"An unknown error occurred removing your role! Please try again later."
))
)
# Update history
now = utc_now()
history = await self.cog.data.RoleMenuHistory.table.update_where(
menuid=self.data.menuid,
roleid=role.id,
userid=member.id,
removed_at=None,
).set(removed_at=now)
await self.cog.cancel_expiring_tasks(*(row['equipid'] for row in history))
# Refund if required
transactionids = [row['transactionid'] for row in history]
if self.config.refunds.value and any(transactionids):
transactionids = [tid for tid in transactionids if tid]
economy: Economy = self.bot.get_cog('Economy')
refunded = await economy.data.Transaction.refund_transactions(*transactionids)
total_refund = sum(row.amount + row.bonus for row in refunded)
else:
total_refund = 0
# Ack the removal
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|deslect|success|title',
"Role removed"
)) ))
) )
if total_refund > 0:
embed.description = t(_p( now = utc_now()
'rolemenu|deselect|success:refund|desc',
"You have removed **{role}**, and been refunded {coin} **{amount}**." # Create transaction if applicable
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund) if price:
if total_refund < 0: economy: Economy = self.bot.get_cog('Economy')
# TODO: Consider disallowing them from removing roles if their balance would go negative tx = await economy.data.Transaction.execute_transaction(
embed.description = t(_p( transaction_type=TransactionType.OTHER,
'rolemenu|deselect|success:negrefund|desc', guildid=guild.id, actorid=member.id,
"You have removed **{role}**, and have lost {coin} **{amount}**." from_account=member.id, to_account=None,
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund) amount=price
else: )
embed.description = t(_p( tid = tx.transactionid
'rolemenu|deselect|success:norefund|desc',
"You have unequipped **{role}**."
)).format(role=role.name)
return embed
else: else:
# Member does not have the role, selection case. tid = None
# Calculate expiry
duration = mrole.config.duration.value
if duration is not None:
expiry = now + dt.timedelta(seconds=duration)
else:
expiry = None
# Add to equip history
equip = await self.cog.data.RoleMenuHistory.create(
menuid=self.data.menuid, roleid=role.id,
userid=member.id,
obtained_at=now,
transactionid=tid,
expires_at=expiry
)
await self.cog.schedule_expiring(equip)
# Ack the selection
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|select|success|title',
"Role equipped"
))
)
if price:
embed.description = t(_p(
'rolemenu|select|success:purchase|desc',
"You have purchased the role **{role}** for {coin}**{amount}**"
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=price)
else:
embed.description = t(_p(
'rolemenu|select|success:nopurchase|desc',
"You have equipped **{role}**"
)).format(role=role.name)
if expiry is not None:
embed.description += '\n' + t(_p(
'rolemenu|select|expires_at',
"The role will expire at {timestamp}."
)).format(
timestamp=discord.utils.format_dt(expiry)
)
if remove_line:
embed.description = '\n'.join((remove_line, embed.description))
# TODO Event logging
return embed
async def _handle_negative(self, lion, member: discord.Member, mrole: RoleMenuRole) -> discord.Embed:
t = self.bot.translator.t
guild = member.guild
role = guild.get_role(mrole.data.roleid)
if not role:
raise ValueError("Calling _handle_negative without a valid role.")
if self.config.sticky.value:
# Cannot deselect
raise UserInputError(
t(_p(
'rolemenu|deselect|error:sticky',
"**{role}** is a sticky role, you cannot remove it with this menu!"
)).format(role=role.name)
)
# Remove the role
try:
await member.remove_roles(role)
except discord.Forbidden:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:perms',
"I don't have enough permissions to remove this role from you!"
))
)
except discord.HTTPException:
raise UserInputError(
t(_p(
'rolemenu|deselect|error:discord',
"An unknown error occurred removing your role! Please try again later."
))
)
# Update history
now = utc_now()
history = await self.cog.data.RoleMenuHistory.table.update_where(
menuid=self.data.menuid,
roleid=role.id,
userid=member.id,
removed_at=None,
).set(removed_at=now)
await self.cog.cancel_expiring_tasks(*(row['equipid'] for row in history))
# Refund if required
transactionids = [row['transactionid'] for row in history]
if self.config.refunds.value and any(transactionids):
transactionids = [tid for tid in transactionids if tid]
economy: Economy = self.bot.get_cog('Economy')
refunded = await economy.data.Transaction.refund_transactions(*transactionids)
total_refund = sum(row.amount + row.bonus for row in refunded)
else:
total_refund = 0
# Ack the removal
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|deslect|success|title',
"Role removed"
))
)
if total_refund > 0:
embed.description = t(_p(
'rolemenu|deselect|success:refund|desc',
"You have removed **{role}**, and been refunded {coin} **{amount}**."
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=total_refund)
if total_refund < 0:
# TODO: Consider disallowing them from removing roles if their balance would go negative
embed.description = t(_p(
'rolemenu|deselect|success:negrefund|desc',
"You have removed **{role}**, and have lost {coin} **{amount}**."
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=-total_refund)
else:
embed.description = t(_p(
'rolemenu|deselect|success:norefund|desc',
"You have unequipped **{role}**."
)).format(role=role.name)
return embed
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
lock_key = ('rmenu', member.id, member.guild.id)
async with self.bot.idlock(lock_key):
# TODO: Selection locking
mrole = self.rolemap.get(menuroleid, None)
if mrole is None:
raise ValueError(
f"Attempt to process event for invalid menuroleid {menuroleid}, THIS SHOULD NOT HAPPEN."
)
guild = member.guild
t = self.bot.translator.t
role = guild.get_role(mrole.data.roleid)
if role is None:
# This role no longer exists, nothing we can do
raise UserInputError(
t(_p(
'rolemenu|error:role_gone',
"The role **{name}** no longer exists!"
)).format(name=mrole.data.label)
)
required = self.config.required_role.data required = self.config.required_role.data
if required is not None: if required is not None:
# Check member has the required role # Check member has the required role
@@ -561,118 +727,12 @@ class RoleMenu:
)).format(role=name) )).format(role=name)
) )
obtainable = self.config.obtainable.value if role in member.roles:
if obtainable is not None: # Member already has the role, deselection case.
# Check shared roles return await self._handle_negative(lion, member, mrole)
menu_roleids = {mrole.data.roleid for mrole in self.roles}
member_roleids = {role.id for role in member.roles}
common = len(menu_roleids.intersection(member_roleids))
if common >= obtainable:
raise UserInputError(
t(_p(
'rolemenu|select|error:max_obtainable',
"You already have the maximum of {obtainable} roles from this menu!"
)).format(obtainable=obtainable)
)
price = mrole.config.price.value
if price:
# Check member balance
# TODO: More transaction safe (or rather check again after transaction)
await lion.data.refresh()
balance = lion.data.coins
if balance < price:
raise UserInputError(
t(_p(
'rolemenu|select|error:insufficient_funds',
"The role **{role}** costs {coin}**{cost}**,"
"but you only have {coin}**{balance}**!"
)).format(
role=role.name,
coin=self.bot.config.emojis.coin,
cost=price,
balance=balance,
)
)
try:
await member.add_roles(role)
except discord.Forbidden:
raise UserInputError(
t(_p(
'rolemenu|select|error:perms',
"I don't have enough permissions to give you this role!"
))
)
except discord.HTTPException:
raise UserInputError(
t(_p(
'rolemenu|select|error:discord',
"An unknown error occurred while assigning your role! "
"Please try again later."
))
)
now = utc_now()
# Create transaction if applicable
if price:
economy: Economy = self.bot.get_cog('Economy')
tx = await economy.data.Transaction.execute_transaction(
transaction_type=TransactionType.OTHER,
guildid=guild.id, actorid=member.id,
from_account=member.id, to_account=None,
amount=price
)
tid = tx.transactionid
else: else:
tid = None # Member does not have the role, selection case.
return await self._handle_positive(lion, member, mrole)
# Calculate expiry
duration = mrole.config.duration.value
if duration is not None:
expiry = now + dt.timedelta(seconds=duration)
else:
expiry = None
# Add to equip history
equip = await self.cog.data.RoleMenuHistory.create(
menuid=self.data.menuid, roleid=role.id,
userid=member.id,
obtained_at=now,
transactionid=tid,
expires_at=expiry
)
await self.cog.schedule_expiring(equip)
# Ack the selection
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'rolemenu|select|success|title',
"Role equipped"
))
)
if price:
embed.description = t(_p(
'rolemenu|select|success:purchase|desc',
"You have purchased the role **{role}** for {coin}**{amount}**"
)).format(role=role.name, coin=self.bot.config.emojis.coin, amount=price)
else:
embed.description = t(_p(
'rolemenu|select|success:nopurchase|desc',
"You have equipped the role **{role}**"
)).format(role=role.name)
if expiry is not None:
embed.description += '\n' + t(_p(
'rolemenu|select|expires_at',
"The role will expire at {timestamp}."
)).format(
timestamp=discord.utils.format_dt(expiry)
)
# TODO Event logging
return embed
async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int): async def interactive_selection(self, interaction: discord.Interaction, menuroleid: int):
""" """

View File

@@ -1144,3 +1144,4 @@ class MenuEditor(MessageUI):
self.pagen = self.pagen % self.page_count self.pagen = self.pagen % self.page_count
self.page_block = blocks[self.pagen] self.page_block = blocks[self.pagen]
await self.menu.fetch_message() await self.menu.fetch_message()
await self.menu.update_raw()

View File

@@ -412,7 +412,7 @@ class RoomCog(LionCog):
t(_p( t(_p(
'cmd:room_rent|error:member_not_found', 'cmd:room_rent|error:member_not_found',
"Could not find the requested member {mention} in this server!" "Could not find the requested member {mention} in this server!"
)).format(member=f"<@{mid}>") )).format(mention=f"<@{mid}>")
), ephemeral=True ), ephemeral=True
) )
return return

View File

@@ -29,7 +29,7 @@ from .settings import ScheduleSettings, ScheduleConfig
from .ui.scheduleui import ScheduleUI from .ui.scheduleui import ScheduleUI
from .ui.settingui import ScheduleSettingUI from .ui.settingui import ScheduleSettingUI
from .core import TimeSlot, ScheduledSession, SessionMember from .core import TimeSlot, ScheduledSession, SessionMember
from .lib import slotid_to_utc, time_to_slotid from .lib import slotid_to_utc, time_to_slotid, format_until
_p, _np = babel._p, babel._np _p, _np = babel._p, babel._np
@@ -243,6 +243,18 @@ class ScheduleCog(LionCog):
logger.debug(f"Getting slotlock <slotid: {slotid}> (locked: {lock.locked()})") logger.debug(f"Getting slotlock <slotid: {slotid}> (locked: {lock.locked()})")
return lock return lock
def get_active_session(self, guildid: int) -> Optional[ScheduledSession]:
"""
Get the current active session for the given guildid, or None if no session is running.
"""
slot = self.active_slots.get(self.nowid, None)
if slot is not None:
return slot.sessions.get(guildid, None)
async def get_config(self, guildid: int) -> ScheduleConfig:
config_data = await self.data.ScheduleGuild.fetch_or_create(guildid)
return ScheduleConfig(guildid, config_data)
@log_wrap(action='Cancel Booking') @log_wrap(action='Cancel Booking')
async def cancel_bookings(self, *bookingids: tuple[int, int, int], refund=True): async def cancel_bookings(self, *bookingids: tuple[int, int, int], refund=True):
""" """
@@ -415,7 +427,10 @@ class ScheduleCog(LionCog):
tasks = [] tasks = []
for (gid, uid), member in to_blacklist.items(): for (gid, uid), member in to_blacklist.items():
role = autoblacklisting[gid][1] role = autoblacklisting[gid][1]
task = asyncio.create_task(member.add_role(role)) task = asyncio.create_task(member.add_roles(
role,
reason="Automatic scheduled session blacklist"
))
tasks.append(task) tasks.append(task)
# TODO: Logging and some error handling # TODO: Logging and some error handling
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
@@ -729,12 +744,29 @@ class ScheduleCog(LionCog):
"View and manage your scheduled session." "View and manage your scheduled session."
) )
) )
@appcmds.rename(
cancel=_p(
'cmd:schedule|param:cancel', "cancel"
),
book=_p(
'cmd:schedule|param:book', "book"
),
)
@appcmds.describe(
cancel=_p(
'cmd:schedule|param:cancel|desc',
"Select a booked timeslot to cancel."
),
book=_p(
'cmd:schedule|param:book|desc',
"Select a timeslot to schedule. (Times shown in your set timezone.)"
),
)
@appcmds.guild_only @appcmds.guild_only
async def schedule_cmd(self, ctx: LionContext): async def schedule_cmd(self, ctx: LionContext,
# TODO: Auotocomplete for book and cancel options cancel: Optional[str] = None,
# Will require TTL caching for member schedules. book: Optional[str] = None,
book = None ):
cancel = None
if not ctx.guild: if not ctx.guild:
return return
if not ctx.interaction: if not ctx.interaction:
@@ -747,6 +779,9 @@ class ScheduleCog(LionCog):
now = utc_now() now = utc_now()
lines: list[tuple[bool, str]] = [] # (error_status, msg) lines: list[tuple[bool, str]] = [] # (error_status, msg)
if book or cancel:
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
if cancel is not None: if cancel is not None:
schedule = await self._fetch_schedule(ctx.author.id) schedule = await self._fetch_schedule(ctx.author.id)
# Validate provided # Validate provided
@@ -756,7 +791,7 @@ class ScheduleCog(LionCog):
'cmd:schedule|cancel_booking|error:parse_slot', 'cmd:schedule|cancel_booking|error:parse_slot',
"Time slot `{provided}` not recognised. " "Time slot `{provided}` not recognised. "
"Please select a session to cancel from the autocomplete options." "Please select a session to cancel from the autocomplete options."
)) )).format(provided=cancel)
line = (True, error) line = (True, error)
elif (slotid := int(cancel)) not in schedule: elif (slotid := int(cancel)) not in schedule:
# Can't cancel slot because it isn't booked # Can't cancel slot because it isn't booked
@@ -799,8 +834,8 @@ class ScheduleCog(LionCog):
'cmd:schedule|create_booking|error:parse_slot', 'cmd:schedule|create_booking|error:parse_slot',
"Time slot `{provided}` not recognised. " "Time slot `{provided}` not recognised. "
"Please select a session to cancel from the autocomplete options." "Please select a session to cancel from the autocomplete options."
)) )).format(provided=book)
lines = (True, error) line = (True, error)
elif (slotid := int(book)) in schedule: elif (slotid := int(book)) in schedule:
# Can't book because the slot is already booked # Can't book because the slot is already booked
error = t(_p( error = t(_p(
@@ -809,7 +844,7 @@ class ScheduleCog(LionCog):
)).format( )).format(
time=discord.utils.format_dt(slotid_to_utc(slotid), style='t') time=discord.utils.format_dt(slotid_to_utc(slotid), style='t')
) )
lines = (True, error) line = (True, error)
elif (slotid_to_utc(slotid) - now).total_seconds() < 60: elif (slotid_to_utc(slotid) - now).total_seconds() < 60:
# Can't book because it is running or about to start # Can't book because it is running or about to start
error = t(_p( error = t(_p(
@@ -823,7 +858,7 @@ class ScheduleCog(LionCog):
# The slotid is valid and bookable # The slotid is valid and bookable
# Run the booking # Run the booking
try: try:
await self.create_booking(guildid, ctx.author.id) await self.create_booking(guildid, ctx.author.id, slotid)
ack = t(_p( ack = t(_p(
'cmd:schedule|create_booking|success', 'cmd:schedule|create_booking|success',
"You have successfully scheduled a session at {time}." "You have successfully scheduled a session at {time}."
@@ -856,6 +891,155 @@ class ScheduleCog(LionCog):
await ui.run(ctx.interaction) await ui.run(ctx.interaction)
await ui.wait() await ui.wait()
@schedule_cmd.autocomplete('book')
async def schedule_cmd_book_acmpl(self, interaction: discord.Interaction, partial: str):
"""
List the sessions available for the member to book.
"""
# TODO: Warning about setting timezone?
userid = interaction.user.id
schedule = await self._fetch_schedule(userid)
t = self.bot.translator.t
if not interaction.guild or not isinstance(interaction.user, discord.Member):
choice = appcmds.Choice(
name=_p(
'cmd:schedule|acmpl:book|error:not_in_guild',
"You need to be in a server to book sessions!"
),
value='None'
)
choices = [choice]
else:
member = interaction.user
# Check blacklist role
blacklist_role = (await self.settings.BlacklistRole.get(interaction.guild.id)).value
if blacklist_role and blacklist_role in member.roles:
choice = appcmds.Choice(
name=_p(
'cmd:schedule|acmpl:book|error:blacklisted',
"Cannot Book -- Blacklisted"
),
value='None'
)
choices = [choice]
else:
nowid = self.nowid
if ((slotid_to_utc(nowid + 3600) - utc_now()).total_seconds() < 60):
# Start from next session instead
nowid += 3600
upcoming = [nowid + 3600 * i for i in range(1, 25)]
upcoming = [slotid for slotid in upcoming if slotid not in schedule]
choices = []
# We can have a max of 25 acmpl choices
# But there are at most 24 sessions to book
# So we can use the top choice for a message
lion = await self.bot.core.lions.fetch_member(interaction.guild.id, member.id, member=member)
tz = lion.timezone
tzstring = t(_p(
'cmd:schedule|acmpl:book|timezone_info',
"Using timezone '{timezone}' where it is '{now}'. Change with '/my timezone'"
)).format(
timezone=str(tz),
now=dt.datetime.now(tz).strftime('%H:%M')
)
choices.append(
appcmds.Choice(
name=tzstring, value='None',
)
)
slot_format = t(_p(
'cmd:schedule|acmpl:book|format',
"{start} - {end} ({until})"
))
for slotid in upcoming:
slot_start = slotid_to_utc(slotid).astimezone(tz).strftime('%H:%M')
slot_end = slotid_to_utc(slotid + 3600).astimezone(tz).strftime('%H:%M')
distance = int((slotid - nowid) // 3600)
until = format_until(t, distance)
name = slot_format.format(
start=slot_start,
end=slot_end,
until=until
)
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
value=str(slotid)
)
)
if len(choices) == 1:
choices.append(
appcmds.Choice(
name=t(_p(
"cmd:schedule|acmpl:book|no_matching",
"No bookable sessions matching '{partial}'"
)).format(partial=partial[:25]),
value=partial
)
)
return choices
@schedule_cmd.autocomplete('cancel')
async def schedule_cmd_cancel_acmpl(self, interaction: discord.Interaction, partial: str):
user = interaction.user
schedule = await self._fetch_schedule(user.id)
t = self.bot.translator.t
choices = []
minid = self.nowid
if ((slotid_to_utc(self.nowid + 3600) - utc_now()).total_seconds() < 60):
minid = minid + 3600
can_cancel = list(slotid for slotid in schedule if slotid > minid)
if not can_cancel:
choice = appcmds.Choice(
name=_p(
'cmd:schedule|acmpl:cancel|error:empty_schedule',
"You do not have any upcoming sessions to cancel!"
),
value='None'
)
choices.append(choice)
else:
lion = await self.bot.core.lions.fetch_member(interaction.guild.id, user.id)
tz = lion.timezone
for slotid in can_cancel:
slot_format = t(_p(
'cmd:schedule|acmpl:book|format',
"{start} - {end} ({until})"
))
slot_start = slotid_to_utc(slotid).astimezone(tz).strftime('%H:%M')
slot_end = slotid_to_utc(slotid + 3600).astimezone(tz).strftime('%H:%M')
distance = int((slotid - minid) // 3600)
until = format_until(t, distance)
name = slot_format.format(
start=slot_start,
end=slot_end,
until=until
)
if partial.lower() in name.lower():
choices.append(
appcmds.Choice(
name=name,
value=str(slotid)
)
)
if not choices:
choice = appcmds.Choice(
name=t(_p(
'cmd:schedule|acmpl:cancel|error:no_matching',
"No cancellable sessions matching '{partial}'"
)).format(partial=partial[:25]),
value='None'
)
choices.append(choice)
return choices
async def _fetch_schedule(self, userid, **kwargs): async def _fetch_schedule(self, userid, **kwargs):
""" """
Fetch the given user's schedule (i.e. booking map) Fetch the given user's schedule (i.e. booking map)
@@ -866,6 +1050,7 @@ class ScheduleCog(LionCog):
bookings = await booking_model.fetch_where( bookings = await booking_model.fetch_where(
booking_model.slotid >= nowid, booking_model.slotid >= nowid,
userid=userid, userid=userid,
**kwargs
).order_by('slotid', ORDER.ASC) ).order_by('slotid', ORDER.ASC)
return { return {

View File

@@ -1,3 +1,4 @@
from random import random
from typing import Optional from typing import Optional
import datetime as dt import datetime as dt
import asyncio import asyncio
@@ -11,7 +12,7 @@ from utils.lib import MessageArgs
from .. import babel, logger from .. import babel, logger
from ..data import ScheduleData as Data from ..data import ScheduleData as Data
from ..lib import slotid_to_utc from ..lib import slotid_to_utc, vacuum_channel
from ..settings import ScheduleSettings as Settings from ..settings import ScheduleSettings as Settings
from ..settings import ScheduleConfig from ..settings import ScheduleConfig
from ..ui.sessionui import SessionUI from ..ui.sessionui import SessionUI
@@ -288,12 +289,16 @@ class ScheduledSession:
Remove overwrites for non-members. Remove overwrites for non-members.
""" """
async with self.lock: async with self.lock:
if not (members := list(self.members.values())):
return
if not (guild := self.guild): if not (guild := self.guild):
return return
if not (room := self.room_channel): if not (room := self.room_channel):
# Nothing to do
self.prepared = True
self.opened = True
return return
members = list(self.members.values())
t = self.bot.translator.t
if room.permissions_for(guild.me) >= my_room_permissions: if room.permissions_for(guild.me) >= my_room_permissions:
# Replace the member overwrites # Replace the member overwrites
@@ -313,17 +318,36 @@ class ScheduledSession:
if mobj: if mobj:
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True) overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
try: try:
await room.edit(overwrites=overwrites) await room.edit(
overwrites=overwrites,
reason=t(_p(
'session|open|update_perms|audit_reason',
"Opening configured scheduled session room."
))
)
except discord.HTTPException: except discord.HTTPException:
logger.exception( logger.exception(
f"Unhandled discord exception received while opening schedule session room {self!r}" f"Unhandled discord exception received while opening schedule session room {self!r}"
) )
else: else:
logger.debug( logger.debug(
f"Opened schedule session room for session {self!r}" f"Opened schedule session room for session {self!r} with overwrites {overwrites}"
)
# Cleanup members who should not be in the channel(s)
if room.type is discord.enums.ChannelType.category:
channels = room.voice_channels
else:
channels = [room]
for channel in channels:
await vacuum_channel(
channel,
reason=t(_p(
'session|open|clean_room|audit_reason',
"Removing extra member from scheduled session room."
))
) )
else: else:
t = self.bot.translator.t
await self.send( await self.send(
t(_p( t(_p(
'session|open|error:room_permissions', 'session|open|error:room_permissions',
@@ -335,20 +359,107 @@ class ScheduledSession:
self.opened = True self.opened = True
@log_wrap(action='Notify') @log_wrap(action='Notify')
async def _notify(self, wait=60): async def _notify(self, ping_wait=10, dm_wait=60):
""" """
Ghost ping members who have not yet attended. Ghost ping members who have not yet attended.
""" """
try: try:
await asyncio.sleep(wait) await asyncio.sleep(ping_wait)
except asyncio.CancelledError: except asyncio.CancelledError:
return return
# Ghost ping alert for missing members
missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None] missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None]
if missing: if missing:
ping = ''.join(f"<@{mid}>" for mid in missing) ping = ''.join(f"<@{mid}>" for mid in missing)
message = await self.send(ping) message = await self.send(ping)
if message is not None: if message is not None:
asyncio.create_task(message.delete()) asyncio.create_task(message.delete())
try:
# Random dither to spread out sharded notifications
dither = 30 * random()
await asyncio.sleep(dm_wait - ping_wait + dither)
except asyncio.CancelledError:
return
if not self.guild:
# In case we somehow left the guild in the meantime
return
# DM alert for _still_ missing members
missing = [mid for mid, m in self.members.items() if m.total_clock == 0 and m.clock_start is None]
for mid in missing:
member = self.guild.get_member(mid)
if member:
args = await self._notify_dm(member)
try:
await member.send(**args.send_args)
except discord.HTTPException:
# Discord really doesn't like failed DM requests
# So take a moment of silence
await asyncio.sleep(1)
async def _notify_dm(self, member: discord.Member) -> MessageArgs:
t = self.bot.translator.t
# Join line depends on guild setup
channels = self.channels_setting.value
room = self.room_channel
if room:
if room.type is discord.enums.ChannelType.category:
join_line = t(_p(
'session|notify|dm|join_line:room_category',
"Please attend your session by joining a voice channel under **{room}**!"
)).format(room=room.name)
else:
join_line = t(_p(
'session|notify|dm|join_line:room_voice',
"Please attend your session by joining {room}"
)).format(room=room.mention)
elif not channels:
join_line = t(_p(
'session|notify|dm|join_line:all_channels',
"Please attend your session by joining a tracked voice channel!"
))
else:
# Expand channels into a list of valid voice channels
voice_channels = set()
for channel in channels:
if channel.type is discord.enums.ChannelType.category:
voice_channels.update(channel.voice_channels)
elif channel.type is discord.enums.ChannelType.voice:
voice_channels.add(channel)
# Now filter by connectivity and tracked
voice_tracker = self.bot.get_cog('VoiceTrackerCog')
valid = []
for channel in voice_channels:
if voice_tracker.is_untracked(channel):
continue
if not channel.permissions_for(member).connect:
continue
valid.append(channel)
join_line = t(_p(
'session|notify|dm|join_line:channels',
"Please attend your session by joining one of the following:"
))
join_line = '\n'.join(join_line, *(channel.mention for channel in valid[:20]))
if len(valid) > 20:
join_line += '\n...'
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'session|notify|dm|title',
"Your Scheduled Session has started!"
)),
description=t(_p(
'session|notify|dm|description',
"Your scheduled session in {dest} has now begun!"
)).format(
dest=self.lobby_channel.mention if self.lobby_channel else f"**{self.guild.name}**"
) + '\n' + join_line
)
return MessageArgs(embed=embed)
def notify(self): def notify(self):
""" """

View File

@@ -19,7 +19,7 @@ from modules.economy.data import EconomyData, TransactionType
from .. import babel, logger from .. import babel, logger
from ..data import ScheduleData as Data from ..data import ScheduleData as Data
from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency, vacuum_channel
from ..settings import ScheduleSettings from ..settings import ScheduleSettings
from .session import ScheduledSession from .session import ScheduledSession
@@ -439,6 +439,75 @@ class TimeSlot:
f"Closed {len(sessions)} for scheduled session timeslot: {self!r}" f"Closed {len(sessions)} for scheduled session timeslot: {self!r}"
) )
@log_wrap(action='Tidy session rooms')
async def tidy_rooms(self, sessions: list[ScheduledSession]):
"""
'Tidy Up' after sessions have been closed.
This cleans up permissions for sessions which do not have another running session,
and vacuums the channel.
Somewhat temporary measure,
workaround for the design flaw that channel permissions are only updated during open,
and hence are never cleared unless there is a next session.
Limitations include not clearing after a manual close.
"""
t = self.bot.translator.t
for session in sessions:
if not session.guild:
# Can no longer access the session guild, nothing to clean up
logger.debug(f"Not tidying {session!r} because guild gone.")
continue
if not (room := session.room_channel):
# Session did not have a room to clean up
logger.debug(f"Not tidying {session!r} because room channel gone.")
continue
if not session.opened or session.cancelled:
# Not an active session, don't try to tidy up
logger.debug(f"Not tidying {session!r} because cancelled or not opened.")
continue
if (active := self.cog.get_active_session(session.guild.id)) is not None:
# Rely on the active session to set permissions and vacuum channel
logger.debug(f"Not tidying {session!r} because guild has active session {active!r}.")
continue
logger.debug(f"Tidying {session!r}.")
me = session.guild.me
if room.permissions_for(me).manage_roles:
overwrites = {
target: overwrite for target, overwrite in room.overwrites.items()
if not isinstance(target, discord.Member)
}
try:
await room.edit(
overwrites=overwrites,
reason=t(_p(
"session|closing|audit_reason",
"Removing previous scheduled session member permissions."
))
)
except discord.HTTPException:
logger.warning(
f"Unexpected exception occurred while tidying after sessions {session!r}",
exc_info=True
)
else:
logger.debug(f"Updated room permissions while tidying {session!r}.")
if room.type is discord.enums.ChannelType.category:
channels = room.voice_channels
else:
channels = [room]
for channel in channels:
await vacuum_channel(
channel,
reason=t(_p(
"session|closing|disconnecting|audit_reason",
"Disconnecting previous scheduled session members."
))
)
logger.debug(f"Finished tidying {session!r}.")
def launch(self) -> asyncio.Task: def launch(self) -> asyncio.Task:
self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}") self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}")
return self.run_task return self.run_task
@@ -475,6 +544,9 @@ class TimeSlot:
logger.info(f"Active timeslot closing. {self!r}") logger.info(f"Active timeslot closing. {self!r}")
await self.close(list(self.sessions.values()), consequences=True) await self.close(list(self.sessions.values()), consequences=True)
logger.info(f"Active timeslot closed. {self!r}") logger.info(f"Active timeslot closed. {self!r}")
await asyncio.sleep(30)
await self.tidy_rooms(list(self.sessions.values()))
logger.info(f"Previous active timeslot tidied up. {self!r}")
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info( logger.info(
f"Deactivating active time slot: {self!r}" f"Deactivating active time slot: {self!r}"

View File

@@ -1,9 +1,15 @@
import asyncio import asyncio
import itertools import itertools
import datetime as dt import datetime as dt
from typing import Optional
from . import logger import discord
from meta.logger import log_wrap
from utils.ratelimits import Bucket from utils.ratelimits import Bucket
from . import logger, babel
_p, _np = babel._p, babel._np
def time_to_slotid(time: dt.datetime) -> int: def time_to_slotid(time: dt.datetime) -> int:
@@ -71,3 +77,39 @@ async def limit_concurrency(aws, limit):
while done: while done:
yield done.pop() yield done.pop()
logger.debug(f"Completed {count} tasks") logger.debug(f"Completed {count} tasks")
def format_until(t, distance):
if distance:
return t(_np(
'ui:schedule|format_until|positive',
"in <1 hour",
"in {number} hours",
distance
)).format(number=distance)
else:
return t(_p(
'ui:schedule|format_until|now',
"right now!"
))
@log_wrap(action='Vacuum Channel')
async def vacuum_channel(channel: discord.VoiceChannel, reason: Optional[str] = None):
"""
Launch disconnect tasks for each voice channel member who does not have permission to connect.
"""
me = channel.guild.me
if not channel.permissions_for(me).move_members:
# Nothing we can do
return
to_remove = [member for member in channel.members if not channel.permissions_for(member).connect]
for member in to_remove:
# Disconnect member from voice
# Extra check here since members may come and go while we are trying to remove
if member in channel.members:
try:
await member.edit(voice_channel=None, reason=reason)
except discord.HTTPException:
pass

View File

@@ -25,6 +25,38 @@ class ScheduleConfig(ModelConfig):
_model_settings = set() _model_settings = set()
model = ScheduleData.ScheduleGuild model = ScheduleData.ScheduleGuild
@property
def session_lobby(self):
return self.get(ScheduleSettings.SessionLobby.setting_id)
@property
def session_room(self):
return self.get(ScheduleSettings.SessionRoom.setting_id)
@property
def schedule_cost(self):
return self.get(ScheduleSettings.ScheduleCost.setting_id)
@property
def attendance_reward(self):
return self.get(ScheduleSettings.AttendanceReward.setting_id)
@property
def attendance_bonus(self):
return self.get(ScheduleSettings.AttendanceBonus.setting_id)
@property
def min_attendance(self):
return self.get(ScheduleSettings.MinAttendance.setting_id)
@property
def blacklist_role(self):
return self.get(ScheduleSettings.BlacklistRole.setting_id)
@property
def blacklist_after(self):
return self.get(ScheduleSettings.BlacklistAfter.setting_id)
class ScheduleSettings(SettingGroup): class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting @ScheduleConfig.register_model_setting
@@ -400,6 +432,7 @@ class ScheduleSettings(SettingGroup):
"Minimum attendance must be an integer number of minutes between `1` and `60`." "Minimum attendance must be an integer number of minutes between `1` and `60`."
)) ))
raise UserInputError(error) raise UserInputError(error)
return num
@ScheduleConfig.register_model_setting @ScheduleConfig.register_model_setting
class BlacklistRole(ModelData, RoleSetting): class BlacklistRole(ModelData, RoleSetting):

View File

@@ -18,7 +18,7 @@ from .scheduleui import ScheduleUI
if TYPE_CHECKING: if TYPE_CHECKING:
from ..cog import ScheduleCog from ..cog import ScheduleCog
_p = babel._p _p, _np = babel._p, babel._np
class SessionUI(LeoUI): class SessionUI(LeoUI):
@@ -59,16 +59,21 @@ class SessionUI(LeoUI):
'ui:sessionui|button:schedule|label', 'ui:sessionui|button:schedule|label',
'Open Schedule' 'Open Schedule'
), locale) ), locale)
self.help_button.label = t(_p(
'ui:sessionui|button:help|label',
"How to Attend"
))
# ----- API ----- # ----- API -----
async def reload(self): async def reload(self):
await self.init_components() await self.init_components()
if self.starting_soon: if self.starting_soon:
# Slot is about to start or slot has already started # Slot is about to start or slot has already started
self.set_layout((self.schedule_button,)) self.set_layout((self.schedule_button, self.help_button))
else: else:
self.set_layout( self.set_layout(
(self.book_button, self.cancel_button, self.schedule_button), (self.book_button, self.cancel_button,),
(self.schedule_button, self.help_button,),
) )
# ----- UI Components ----- # ----- UI Components -----
@@ -178,3 +183,141 @@ class SessionUI(LeoUI):
ui = ScheduleUI(self.bot, press.guild, press.user.id) ui = ScheduleUI(self.bot, press.guild, press.user.id)
await ui.run(press) await ui.run(press)
await ui.wait() await ui.wait()
@button(label='HELP_PLACEHOLDER', style=ButtonStyle.grey, emoji=conf.emojis.question)
async def help_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
t = self.bot.translator.t
babel = self.bot.get_cog('BabelCog')
locale = await babel.get_user_locale(press.user.id)
ctx_locale.set(locale)
schedule = await self.cog._fetch_schedule(press.user.id)
if self.slotid not in schedule:
# Tell them how to book
embed = discord.Embed(
colour=discord.Colour.brand_red(),
title=t(_p(
'ui:session|button:help|embed:unbooked|title',
'You have not booked this session!'
)),
description=t(_p(
'ui:session|button:help|embed:unbooked|description',
"You need to book this scheduled session before you can attend it! "
"Press the **{book_label}** button to book the session."
)).format(book_label=self.book_button.label),
)
else:
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'ui:session|button:help|embed:help|title',
"How to attend your scheduled session"
))
)
config = await self.cog.get_config(self.guildid)
# Get required duration, and format it
duration = config.min_attendance.value
durstring = t(_np(
'ui:session|button:help|embed:help|minimum_attendance',
"at least one minute",
"at least `{duration}` minutes",
duration
)).format(duration=duration)
# Get session room
room = config.session_room.value
if room is None:
room_line = ''
elif room.type is discord.enums.ChannelType.category:
room_line = t(_p(
'ui:session|button:help|embed:help|room_line:category',
"The exclusive scheduled session category **{category}** "
"will also be open to you during your scheduled session."
)).format(category=room.name)
else:
room_line = t(_p(
'ui:session|button:help|embed:help|room_line:voice',
"The exclusive scheduled session room {room} "
"will also be open to you during your scheduled session."
)).format(room=room.mention)
# Get valid session channels, if set
channels = (await self.cog.settings.SessionChannels.get(self.guildid)).value
attend_args = dict(
minimum=durstring,
start=discord.utils.format_dt(slotid_to_utc(self.slotid), 't'),
end=discord.utils.format_dt(slotid_to_utc(self.slotid + 3600), 't'),
)
if room is not None and len(channels) == 1 and channels[0].id == room.id:
# Special case where session room is the only allowed channel/category
room_line = ''
if room.type is discord.enums.ChannelType.category:
attend_line = t(_p(
'ui:session|button:help|embed:help|attend_line:only_room_category',
"To attend your scheduled session, "
"join a voice channel in **{room}** for **{minimum}** "
"between {start} and {end}."
)).format(
**attend_args,
room=room.name
)
else:
attend_line = t(_p(
'ui:session|button:help|embed:help|attend_line:only_room_channel',
"To attend your scheduled session, "
"join {room} for **{minimum}** "
"between {start} and {end}."
)).format(
**attend_args,
room=room.mention
)
elif channels:
attend_line = t(_p(
'ui:session|button:help|embed:help|attend_line:with_channels',
"To attend your scheduled session, join a valid session voice channel for **{minimum}** "
"between {start} and {end}."
)).format(**attend_args)
channel_string = ', '.join(
f"**{channel.name}**" if (channel.type == discord.enums.ChannelType.category) else channel.mention
for channel in channels
)
embed.add_field(
name=t(_p(
'ui:session|button:help|embed:help|field:channels|name',
"Valid session channels"
)),
value=channel_string[:1024],
inline=False
)
else:
attend_line = t(_p(
'ui:session|button:help|embed:help|attend_line:all_channels',
"To attend your scheduled session, join any tracked voice channel "
"for **{minimum}** between {start} and {end}."
)).format(**attend_args)
embed.description = '\n'.join((attend_line, room_line))
embed.add_field(
name=t(_p(
'ui:session|button:help|embed:help|field:rewards|name',
"Rewards"
)),
value=t(_p(
'ui:session|button:help|embed:help|field:rewards|value',
"Everyone who attends the session will be rewarded with {coin}**{reward}**.\n"
"If *everyone* successfully attends, you will also be awarded a bonus of {coin}**{bonus}**.\n"
"Anyone who does *not* attend their booked session will have the rest of their schedule cancelled "
"**without refund**, so beware!"
)).format(
coin=conf.emojis.coin,
reward=config.attendance_reward.value,
bonus=config.attendance_bonus.value,
),
inline=False
)
await press.edit_original_response(embed=embed)

View File

@@ -46,6 +46,7 @@ class StatsCog(LionCog):
"Display your personal profile and summary statistics." "Display your personal profile and summary statistics."
) )
) )
@appcmds.guild_only
async def me_cmd(self, ctx: LionContext): async def me_cmd(self, ctx: LionContext):
await ctx.interaction.response.defer(thinking=True) await ctx.interaction.response.defer(thinking=True)
ui = ProfileUI(self.bot, ctx.author, ctx.guild) ui = ProfileUI(self.bot, ctx.author, ctx.guild)
@@ -59,6 +60,7 @@ class StatsCog(LionCog):
"Weekly and monthly statistics for your recent activity." "Weekly and monthly statistics for your recent activity."
) )
) )
@appcmds.guild_only
async def stats_cmd(self, ctx: LionContext): async def stats_cmd(self, ctx: LionContext):
""" """
Statistics command. Statistics command.

View File

@@ -126,7 +126,7 @@ class StatsData(Registry):
@classmethod @classmethod
@log_wrap(action='study_times_between') @log_wrap(action='study_times_between')
async def study_times_between(cls, guildid: int, userid: int, *points) -> list[int]: async def study_times_between(cls, guildid: Optional[int], userid: int, *points) -> list[int]:
if len(points) < 2: if len(points) < 2:
raise ValueError('Not enough block points given!') raise ValueError('Not enough block points given!')
@@ -165,8 +165,8 @@ class StatsData(Registry):
return (await cursor.fetchone()[0]) or 0 return (await cursor.fetchone()[0]) or 0
@classmethod @classmethod
@log_wrap(action='study_times_between') @log_wrap(action='study_times_since')
async def study_times_since(cls, guildid: int, userid: int, *starts) -> int: async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> int:
if len(starts) < 1: if len(starts) < 1:
raise ValueError('No starting points given!') raise ValueError('No starting points given!')

View File

@@ -76,7 +76,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
else: else:
next_rank = None next_rank = None
achievements = (0, 1) achievements = (0, 1, 2, 3)
card = ProfileCard( card = ProfileCard(
user=username, user=username,

View File

@@ -34,7 +34,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
# Extract the study times for each period # Extract the study times for each period
if mode in (CardMode.STUDY, CardMode.VOICE): if mode in (CardMode.STUDY, CardMode.VOICE):
model = data.VoiceSessionStats model = data.VoiceSessionStats
refkey = (guildid, userid) refkey = (guildid or None, userid)
ref_since = model.study_times_since ref_since = model.study_times_since
ref_between = model.study_times_between ref_between = model.study_times_between
elif mode is CardMode.TEXT: elif mode is CardMode.TEXT:

View File

@@ -310,6 +310,22 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
""" """
name = self.display_name name = self.display_name
value = f"{self.long_desc}\n{self.desc_table}" value = f"{self.long_desc}\n{self.desc_table}"
if len(value) > 1024:
t = ctx_translator.get().t
desc_table = '\n'.join(
tabulate(
*self._desc_table(
show_value=t(_p(
'setting|embed_field|too_long',
"Too long to display here!"
))
)
)
)
value = f"{self.long_desc}\n{desc_table}"
if len(value) > 1024:
# Forcibly trim
value = value[:1020] + '...'
return {'name': name, 'value': value} return {'name': name, 'value': value}
@property @property
@@ -341,14 +357,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table) embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table)
return embed return embed
def _desc_table(self) -> list[str]: def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
t = ctx_translator.get().t t = ctx_translator.get().t
lines = [] lines = []
# Currently line # Currently line
lines.append(( lines.append((
t(_p('setting|summary_table|field:currently|key', "Currently")), t(_p('setting|summary_table|field:currently|key', "Currently")),
self.formatted or self.notset_str show_value or (self.formatted or self.notset_str)
)) ))
# Default line # Default line
@@ -380,7 +396,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
return TextInput( return TextInput(
label=self.display_name, label=self.display_name,
placeholder=self.accepts, placeholder=self.accepts,
default=self.input_formatted, default=self.input_formatted[:4000],
required=self._required required=self._required
) )

View File

@@ -148,6 +148,12 @@ class TextTrackerCog(LionCog):
logger.info( logger.info(
f"Saving batch of {len(batch)} completed text sessions." f"Saving batch of {len(batch)} completed text sessions."
) )
if self.bot.core is None or self.bot.core.lions is None:
# Currently unloading, nothing we can do
logger.warning(
"Skipping text session batch due to unloaded modules."
)
return
# Batch-fetch lguilds # Batch-fetch lguilds
lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch}) lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch})
@@ -223,6 +229,7 @@ class TextTrackerCog(LionCog):
channel.category_id channel.category_id
except discord.ClientException: except discord.ClientException:
logger.debug(f"Ignoring message from channel with no parent: {message.channel}") logger.debug(f"Ignoring message from channel with no parent: {message.channel}")
return
# Untracked channel ward # Untracked channel ward
untracked = self.untracked_channels.get(guildid, []) untracked = self.untracked_channels.get(guildid, [])

View File

@@ -105,12 +105,15 @@ class TextSession:
""" """
Process a message into the session. Process a message into the session.
""" """
if not message.guild:
return
if (message.author.id != self.userid) or (message.guild.id != self.guildid): if (message.author.id != self.userid) or (message.guild.id != self.guildid):
raise ValueError("Invalid attempt to process message from a different member!") raise ValueError("Invalid attempt to process message from a different member!")
# Identify if we need to start a new period # Identify if we need to start a new period
tdiff = (message.created_at - self.this_period_start).total_seconds() start = self.this_period_start
if self.this_period_start is not None and tdiff < self.period_length: if start is not None and (message.created_at - start).total_seconds() < self.period_length:
self.this_period_messages += 1 self.this_period_messages += 1
self.this_period_words += len(message.content.split()) self.this_period_words += len(message.content.split())
else: else:

View File

@@ -79,6 +79,18 @@ class VoiceTrackerCog(LionCog):
""" """
return VoiceSession.get(self.bot, guildid, userid, **kwargs) return VoiceSession.get(self.bot, guildid, userid, **kwargs)
def is_untracked(self, channel) -> bool:
if not channel.guild:
raise ValueError("Untracked check invalid for private channels.")
untracked = self.untracked_channels.get(channel.guild.id, ())
if channel.id in untracked:
untracked = True
elif channel.category_id and channel.category_id in untracked:
untracked = True
else:
untracked = False
return untracked
@LionCog.listener('on_ready') @LionCog.listener('on_ready')
@log_wrap(action='Init Voice Sessions') @log_wrap(action='Init Voice Sessions')
async def initialise(self): async def initialise(self):

View File

@@ -1,7 +1,7 @@
""" """
Some useful pre-built Conditions for data queries. Some useful pre-built Conditions for data queries.
""" """
from typing import Optional from typing import Optional, Any
from itertools import chain from itertools import chain
from psycopg import sql from psycopg import sql
@@ -11,7 +11,7 @@ from data.base import Expression
from constants import MAX_COINS from constants import MAX_COINS
def MULTIVALUE_IN(columns: tuple[str, ...], *data: tuple[...]) -> Condition: def MULTIVALUE_IN(columns: tuple[str, ...], *data: tuple[Any, ...]) -> Condition:
""" """
Condition constructor for filtering by multiple column equalities. Condition constructor for filtering by multiple column equalities.
@@ -100,7 +100,7 @@ class TemporaryTable(Expression):
``` ```
""" """
def __init__(self, *columns: str, name: str = '_t', types: Optional[tuple[str]] = None): def __init__(self, *columns: str, name: str = '_t', types: Optional[tuple[str, ...]] = None):
self.name = name self.name = name
self.columns = columns self.columns = columns
self.types = types self.types = types

View File

@@ -4,6 +4,7 @@ import datetime
import iso8601 # type: ignore import iso8601 # type: ignore
import pytz import pytz
import re import re
import json
from contextvars import Context from contextvars import Context
import discord import discord
@@ -764,7 +765,7 @@ class Timezoned:
Return the start of the current month in the object's timezone Return the start of the current month in the object's timezone
""" """
today = self.today today = self.today
return today - datetime.timedelta(days=today.day) return today - datetime.timedelta(days=(today.day - 1))
def replace_multiple(format_string, mapping): def replace_multiple(format_string, mapping):
@@ -829,3 +830,60 @@ async def check_dm(user: discord.User | discord.Member) -> bool:
return False return False
except discord.HTTPException: except discord.HTTPException:
return True return True
async def command_lengths(tree) -> dict[str, int]:
cmds = tree.get_commands()
payloads = [
await cmd.get_translated_payload(tree.translator)
for cmd in cmds
]
lens = {}
for command in payloads:
name = command['name']
crumbs = {}
cmd_len = lens[name] = _recurse_length(command, crumbs, (name,))
if name == 'configure' or cmd_len > 4000:
print(f"'{name}' over 4000. Breadcrumb Trail follows:")
lines = []
for loc, val in crumbs.items():
locstr = '.'.join(loc)
lines.append(f"{locstr}: {val}")
print('\n'.join(lines))
print(json.dumps(command, indent=2))
return lens
def _recurse_length(payload, breadcrumbs={}, header=()) -> int:
total = 0
total_header = (*header, '')
breadcrumbs[total_header] = 0
if isinstance(payload, dict):
# Read strings that count towards command length
# String length is length of longest localisation, including default.
for key in ('name', 'description', 'value'):
if key in payload:
value = payload[key]
if isinstance(value, str):
values = (value, *payload.get(key + '_localizations', {}).values())
maxlen = max(map(len, values))
total += maxlen
breadcrumbs[(*header, key)] = maxlen
for key, value in payload.items():
loc = (*header, key)
total += _recurse_length(value, breadcrumbs, loc)
elif isinstance(payload, list):
for i, item in enumerate(payload):
if isinstance(item, dict) and 'name' in item:
loc = (*header, f"{i}<{item['name']}>")
else:
loc = (*header, str(i))
total += _recurse_length(item, breadcrumbs, loc)
if total:
breadcrumbs[total_header] = total
else:
breadcrumbs.pop(total_header)
return total

View File

@@ -56,7 +56,10 @@ class Bucket:
def delay(self): def delay(self):
self._leak() self._leak()
if self._level + 1 > self.max_level: if self._level + 1 > self.max_level:
return (self._level + 1 - self.max_level) * self.leak_rate delay = (self._level + 1 - self.max_level) * self.leak_rate
else:
delay = 0
return delay
def _leak(self): def _leak(self):
if self._level: if self._level:

View File

@@ -174,6 +174,31 @@ class MsgEditor(MessageUI):
"Add Embed" "Add Embed"
)) ))
@button(label="RM_EMBED_BUTTON_PLACEHOLDER", style=ButtonStyle.red)
async def rm_embed_button(self, press: discord.Interaction, pressed: Button):
"""
Remove the existing embed from the message.
"""
await press.response.defer()
t = self.bot.translator.t
data = self.copy_data()
data.pop('embed', None)
data.pop('embeds', None)
if not data.get('content', '').strip():
data['content'] = t(_p(
'ui:msg_editor|button:rm_embed|sample_content',
"Content Placeholder"
))
await self.push_change(data)
async def rm_embed_button_refresh(self):
t = self.bot.translator.t
button = self.rm_embed_button
button.label = t(_p(
'ui:msg_editor|button:rm_embed|label',
"Remove Embed"
))
# -- Embed Mode -- # -- Embed Mode --
@button(label="BODY_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple) @button(label="BODY_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
@@ -927,7 +952,7 @@ class MsgEditor(MessageUI):
return MessageArgs(**args) return MessageArgs(**args)
async def refresh_layout(self): async def refresh_layout(self):
await asyncio.gather( to_refresh = (
self.edit_button_refresh(), self.edit_button_refresh(),
self.add_embed_button_refresh(), self.add_embed_button_refresh(),
self.body_button_refresh(), self.body_button_refresh(),
@@ -941,12 +966,16 @@ class MsgEditor(MessageUI):
self.download_button_refresh(), self.download_button_refresh(),
self.undo_button_refresh(), self.undo_button_refresh(),
self.redo_button_refresh(), self.redo_button_refresh(),
self.rm_embed_button_refresh(),
) )
await asyncio.gather(*to_refresh)
if self.history[-1].get('embed', None): if self.history[-1].get('embed', None):
self.set_layout( self.set_layout(
(self.body_button, self.author_button, self.footer_button, self.images_button, self.add_field_button), (self.body_button, self.author_button, self.footer_button, self.images_button, self.add_field_button),
(self.edit_field_menu,), (self.edit_field_menu,),
(self.delete_field_menu,), (self.delete_field_menu,),
(self.rm_embed_button,),
(self.save_button, self.download_button, self.undo_button, self.redo_button, self.quit_button), (self.save_button, self.download_button, self.undo_button, self.redo_button, self.quit_button),
) )
else: else: