Merge pull request #55 from StudyLions/rewrite
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
2
src/gui
2
src/gui
Submodule src/gui updated: ba9ace6ced...24e94d10e2
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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!')
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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, [])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user