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/*
|
||||
|
||||
pending-rewrite/
|
||||
logs/*
|
||||
notes/*
|
||||
tmp/*
|
||||
output/*
|
||||
locales/domains
|
||||
|
||||
.idea/*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
aiohttp==3.7.4.post0
|
||||
cachetools==4.2.2
|
||||
configparser==5.0.2
|
||||
discord.py
|
||||
discord.py [voice]
|
||||
iso8601==0.1.16
|
||||
psycopg[pool]
|
||||
pytz==2021.1
|
||||
|
||||
@@ -145,7 +145,7 @@ class BabelCog(LionCog):
|
||||
t(_p(
|
||||
'cmd:configure_language|error',
|
||||
"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
|
||||
lines = []
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
|
||||
from settings import ModelData
|
||||
from settings.setting_types import StringSetting, BoolSetting
|
||||
@@ -23,11 +24,11 @@ class LocaleSetting(StringSetting):
|
||||
"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()
|
||||
t = translator.t
|
||||
|
||||
lines = super()._desc_table()
|
||||
lines = super()._desc_table(show_value=show_value)
|
||||
lines.append((
|
||||
t(_p(
|
||||
'settype:locale|summary_table|field:supported|key',
|
||||
|
||||
@@ -11,7 +11,7 @@ from discord.enums import Locale
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SOURCE_LOCALE = 'en-GB'
|
||||
SOURCE_LOCALE = 'en_GB'
|
||||
ctx_locale: ContextVar[str] = ContextVar('locale', default=SOURCE_LOCALE)
|
||||
ctx_translator: ContextVar['LeoBabel'] = ContextVar('translator', default=None) # type: ignore
|
||||
|
||||
@@ -71,6 +71,7 @@ class LeoBabel(Translator):
|
||||
self.translators.clear()
|
||||
|
||||
def get_translator(self, locale, domain):
|
||||
locale = locale.replace('-', '_') if locale else None
|
||||
if locale == SOURCE_LOCALE:
|
||||
translator = null
|
||||
elif locale in self.supported_locales and domain in self.supported_domains:
|
||||
@@ -93,7 +94,8 @@ class LeoBabel(Translator):
|
||||
return lazystr._translate_with(translator)
|
||||
|
||||
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)
|
||||
if domain is None and isinstance(string, LazyStr):
|
||||
logger.debug(
|
||||
@@ -101,7 +103,7 @@ class LeoBabel(Translator):
|
||||
)
|
||||
return None
|
||||
|
||||
translator = self.get_translator(locale.value, domain)
|
||||
translator = self.get_translator(loc, domain)
|
||||
if not isinstance(string, LazyStr):
|
||||
lazy = LazyStr(Method.GETTEXT, string.message)
|
||||
else:
|
||||
|
||||
@@ -70,7 +70,7 @@ async def main():
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with LionBot(
|
||||
command_prefix=commands.when_mentioned,
|
||||
command_prefix='!leo!',
|
||||
intents=intents,
|
||||
appname=appname,
|
||||
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
|
||||
return await cls.execute_transactions(*records)
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
class ShopTransaction(RowModel):
|
||||
"""
|
||||
|
||||
@@ -55,6 +55,7 @@ class MemberAdminSettings(SettingGroup):
|
||||
|
||||
_model = CoreData.Guild
|
||||
_column = CoreData.Guild.greeting_channel.name
|
||||
_allow_object = False
|
||||
|
||||
@property
|
||||
def update_message(self) -> str:
|
||||
@@ -188,8 +189,8 @@ class MemberAdminSettings(SettingGroup):
|
||||
self.value = editor_data
|
||||
await self.write()
|
||||
|
||||
def _desc_table(self) -> list[str]:
|
||||
lines = super()._desc_table()
|
||||
def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
|
||||
lines = super()._desc_table(show_value=show_value)
|
||||
t = ctx_translator.get().t
|
||||
keydescs = [
|
||||
(key, t(value)) for key, value in self._subkey_desc.items()
|
||||
@@ -313,8 +314,8 @@ class MemberAdminSettings(SettingGroup):
|
||||
self.value = editor_data
|
||||
await self.write()
|
||||
|
||||
def _desc_table(self) -> list[str]:
|
||||
lines = super()._desc_table()
|
||||
def _desc_table(self, show_value: Optional[str] = None) -> list[tuple[str, str]]:
|
||||
lines = super()._desc_table(show_value=show_value)
|
||||
t = ctx_translator.get().t
|
||||
keydescs = [
|
||||
(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.monitor import ComponentMonitor, ComponentStatus, StatusLevel
|
||||
from utils.lib import utc_now
|
||||
from utils.ratelimits import limit_concurrency
|
||||
|
||||
from wards import low_management_ward
|
||||
|
||||
@@ -48,16 +49,38 @@ class TimerCog(LionCog):
|
||||
self.timer_options = TimerOptions()
|
||||
|
||||
self.ready = False
|
||||
self.timers = defaultdict(dict)
|
||||
self.timers: dict[int, dict[int, Timer]] = defaultdict(dict)
|
||||
|
||||
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:
|
||||
level = StatusLevel.STARTING
|
||||
info = "(STARTING) Not ready. {timers} timers loaded."
|
||||
info = f"(STARTING) Not ready. {state}"
|
||||
else:
|
||||
level = StatusLevel.OKAY
|
||||
info = "(OK) {timers} timers loaded."
|
||||
data = dict(timers=len(self.timers))
|
||||
info = f"(OK) Ready. {state}"
|
||||
return ComponentStatus(level, info, info, data)
|
||||
|
||||
async def cog_load(self):
|
||||
@@ -79,15 +102,12 @@ class TimerCog(LionCog):
|
||||
Clears caches and stops run-tasks for each active timer.
|
||||
Does not exist until all timers have completed background tasks.
|
||||
"""
|
||||
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`"
|
||||
)
|
||||
timers = [timer for tguild in self.timers.values() for timer in tguild.values()]
|
||||
self.timers.clear()
|
||||
|
||||
if timers:
|
||||
await self._unload_timers(timers)
|
||||
|
||||
async def cog_check(self, ctx: LionContext):
|
||||
if not self.ready:
|
||||
raise CheckFailure(
|
||||
@@ -101,6 +121,20 @@ class TimerCog(LionCog):
|
||||
else:
|
||||
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]):
|
||||
"""
|
||||
Factored method to load a list of timers from data rows.
|
||||
@@ -108,6 +142,7 @@ class TimerCog(LionCog):
|
||||
guildids = set()
|
||||
to_delete = []
|
||||
to_create = []
|
||||
to_unload = []
|
||||
for row in timer_data:
|
||||
channel = self.bot.get_channel(row.channelid)
|
||||
if not channel:
|
||||
@@ -115,6 +150,12 @@ class TimerCog(LionCog):
|
||||
else:
|
||||
guildids.add(row.guildid)
|
||||
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:
|
||||
lguilds = await self.bot.core.lions.fetch_guilds(*guildids)
|
||||
@@ -145,37 +186,57 @@ class TimerCog(LionCog):
|
||||
# Re-launch and update running timers
|
||||
for timer in to_launch:
|
||||
timer.launch()
|
||||
tasks = [
|
||||
asyncio.create_task(timer.update_status_card()) for timer in to_launch
|
||||
]
|
||||
if tasks:
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception occurred updating timer status for running timers."
|
||||
)
|
||||
|
||||
coros = [timer.update_status_card() for timer in to_launch]
|
||||
if coros:
|
||||
i = 0
|
||||
async for task in limit_concurrency(coros, 10):
|
||||
try:
|
||||
await task
|
||||
except discord.HTTPException:
|
||||
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(
|
||||
f"Updated and launched {len(to_launch)} running timers."
|
||||
)
|
||||
|
||||
# Update stopped timers
|
||||
tasks = [
|
||||
asyncio.create_task(timer.update_status_card()) for timer in to_update
|
||||
]
|
||||
if tasks:
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception occurred updating timer status for stopped timers."
|
||||
)
|
||||
coros = [timer.update_status_card(render=False) for timer in to_update]
|
||||
if coros:
|
||||
i = 0
|
||||
async for task in limit_concurrency(coros, 10):
|
||||
try:
|
||||
await task
|
||||
except discord.HTTPException:
|
||||
timer = to_update[i]
|
||||
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(
|
||||
f"Updated {len(to_update)} stopped timers."
|
||||
)
|
||||
|
||||
# Update timer registry
|
||||
self.timers.update(timer_reg)
|
||||
for gid, gtimers in timer_reg.items():
|
||||
self.timers[gid].update(gtimers)
|
||||
|
||||
@LionCog.listener('on_ready')
|
||||
@log_wrap(action='Init Timers')
|
||||
@@ -185,10 +246,14 @@ class TimerCog(LionCog):
|
||||
"""
|
||||
self.ready = False
|
||||
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
|
||||
# TODO: Join with guilds and filter by guilds we are still in
|
||||
timer_data = await self.data.Timer.fetch_where(THIS_SHARD)
|
||||
guildids = [guild.id for guild in self.bot.guilds]
|
||||
timer_data = await self.data.Timer.fetch_where(guildid=guildids)
|
||||
await self._load_timers(timer_data)
|
||||
|
||||
# Ready to handle events
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import timedelta, datetime
|
||||
import discord
|
||||
|
||||
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 core.lion_guild import LionGuild
|
||||
from core.data import CoreData
|
||||
@@ -61,7 +61,7 @@ class Timer:
|
||||
log_context.set(f"tid: {self.data.channelid}")
|
||||
|
||||
# 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.last_status_message: Optional[discord.Message] = None # Last deliever notification message
|
||||
self._hook: Optional[CoreData.LionHook] = None # Cached notification webhook
|
||||
@@ -384,7 +384,7 @@ class Timer:
|
||||
tasks = []
|
||||
after_tasks = []
|
||||
# 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)):
|
||||
now = utc_now()
|
||||
@@ -397,38 +397,65 @@ class Timer:
|
||||
elif last_seen < threshold:
|
||||
needs_kick.append(member)
|
||||
|
||||
for member in needs_kick:
|
||||
tasks.append(member.edit(voice_channel=None))
|
||||
t = self.bot.translator.t
|
||||
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()
|
||||
if needs_kick and notify_hook:
|
||||
t = self.bot.translator.t
|
||||
kick_message = t(_np(
|
||||
'timer|kicked_message',
|
||||
"{mentions} was removed from {channel} because they were inactive! "
|
||||
"Remember to press {tick} to register your presence every stage.",
|
||||
"{mentions} were removed from {channel} because they were inactive! "
|
||||
"Remember to press {tick} to register your presence every stage.",
|
||||
len(needs_kick)
|
||||
), locale=self.locale.value).format(
|
||||
channel=f"<#{self.data.channelid}>",
|
||||
mentions=', '.join(member.mention for member in needs_kick),
|
||||
tick=self.bot.config.emojis.tick
|
||||
)
|
||||
tasks.append(notify_hook.send(kick_message))
|
||||
if needs_kick and notify_hook and self.channel:
|
||||
if self.channel.permissions_for(self.channel.guild.me).move_members:
|
||||
kick_message = t(_np(
|
||||
'timer|kicked_message',
|
||||
"{mentions} was removed from {channel} because they were inactive! "
|
||||
"Remember to press {tick} to register your presence every stage.",
|
||||
"{mentions} were removed from {channel} because they were inactive! "
|
||||
"Remember to press {tick} to register your presence every stage.",
|
||||
len(needs_kick)
|
||||
), locale=self.locale.value).format(
|
||||
channel=f"<#{self.data.channelid}>",
|
||||
mentions=', '.join(member.mention for member in needs_kick),
|
||||
tick=self.bot.config.emojis.tick
|
||||
)
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
print("Sent Status")
|
||||
|
||||
if after_tasks:
|
||||
try:
|
||||
@@ -444,7 +471,7 @@ class Timer:
|
||||
if not stage:
|
||||
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
|
||||
|
||||
async with self.lguild.voice_lock:
|
||||
@@ -480,15 +507,16 @@ class Timer:
|
||||
|
||||
# Quit when we finish playing or after 10 seconds, whichever comes first
|
||||
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)
|
||||
for task in pending:
|
||||
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:
|
||||
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):
|
||||
@@ -511,7 +539,7 @@ class Timer:
|
||||
)
|
||||
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.
|
||||
"""
|
||||
@@ -520,7 +548,7 @@ class Timer:
|
||||
ctx_locale.set(self.locale.value)
|
||||
stage = self.current_stage
|
||||
|
||||
if self.running:
|
||||
if self.running and stage is not None:
|
||||
stageline = self.stageline(stage)
|
||||
warningline = ""
|
||||
needs_warning = []
|
||||
@@ -530,7 +558,7 @@ class Timer:
|
||||
last_seen = self.last_seen.get(member.id, None)
|
||||
if last_seen is None:
|
||||
last_seen = self.last_seen[member.id] = now
|
||||
elif last_seen < threshold:
|
||||
elif threshold and last_seen < threshold:
|
||||
needs_warning.append(member)
|
||||
if needs_warning:
|
||||
warningline = t(_p(
|
||||
@@ -567,13 +595,16 @@ class Timer:
|
||||
|
||||
await ui.refresh()
|
||||
|
||||
card = await get_timer_card(self.bot, self, stage)
|
||||
try:
|
||||
await card.render()
|
||||
file = card.as_file(f"pomodoro_{self.data.channelid}.png")
|
||||
args = MessageArgs(content=content, file=file, view=ui)
|
||||
except RenderingException:
|
||||
args = MessageArgs(content=content, view=ui)
|
||||
rawargs = dict(content=content, view=ui)
|
||||
|
||||
if render:
|
||||
try:
|
||||
card = await get_timer_card(self.bot, self, stage)
|
||||
await card.render()
|
||||
rawargs['file'] = card.as_file(f"pomodoro_{self.data.channelid}.png")
|
||||
except RenderingException:
|
||||
pass
|
||||
args = MessageArgs(**rawargs)
|
||||
|
||||
return args
|
||||
|
||||
@@ -764,12 +795,16 @@ class Timer:
|
||||
f"Timer <tid: {channelid}> deleted. Reason given: {reason!r}"
|
||||
)
|
||||
|
||||
@log_wrap(action='Timer Loop')
|
||||
@log_wrap(isolate=True, stack=())
|
||||
async def _runloop(self):
|
||||
"""
|
||||
Main loop which controls the
|
||||
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
|
||||
drift = 10
|
||||
|
||||
@@ -785,6 +820,11 @@ class Timer:
|
||||
|
||||
self._state = current = self.current_stage
|
||||
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()
|
||||
|
||||
# TODO: Consider request rate and load
|
||||
@@ -812,12 +852,18 @@ class Timer:
|
||||
|
||||
if current.end < utc_now():
|
||||
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)
|
||||
task.add_done_callback(background_tasks.discard)
|
||||
current = self._state
|
||||
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)
|
||||
task.add_done_callback(background_tasks.discard)
|
||||
task = asyncio.create_task(self.update_status_card())
|
||||
@@ -825,7 +871,13 @@ class Timer:
|
||||
task.add_done_callback(background_tasks.discard)
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -269,6 +269,9 @@ class RankCog(LionCog):
|
||||
Handle batch of completed message sessions.
|
||||
"""
|
||||
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)
|
||||
rank_type = lguild.config.get('rank_type').value
|
||||
if rank_type in (RankType.MESSAGE, RankType.XP):
|
||||
@@ -542,6 +545,9 @@ class RankCog(LionCog):
|
||||
@log_wrap(action="Voice Rank Hook")
|
||||
async def on_voice_session_complete(self, *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)
|
||||
unranked_role_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(guildid)
|
||||
unranked_roleids = set(unranked_role_setting.data)
|
||||
|
||||
@@ -126,6 +126,7 @@ async def rolemenu_ctxcmd(interaction: discord.Interaction, message: discord.Mes
|
||||
else:
|
||||
menu = await RoleMenu.fetch(self.bot, menuid)
|
||||
menu._message = message
|
||||
await menu.update_raw()
|
||||
|
||||
# Open the editor
|
||||
editor = MenuEditor(self.bot, menu, callerid=interaction.user.id)
|
||||
@@ -732,7 +733,7 @@ class RoleMenuCog(LionCog):
|
||||
|
||||
# Parse menu options if given
|
||||
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:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
@@ -895,6 +896,7 @@ class RoleMenuCog(LionCog):
|
||||
)).format(name=name)
|
||||
)
|
||||
await target.fetch_message()
|
||||
await target.update_raw()
|
||||
|
||||
# Parse provided options
|
||||
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: 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:
|
||||
msg_config = target.config.rawmessage
|
||||
@@ -1019,6 +1055,11 @@ class RoleMenuCog(LionCog):
|
||||
)).format(channel=channel.mention, exception=e.text))
|
||||
else:
|
||||
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
|
||||
if ack_lines or error_lines:
|
||||
|
||||
@@ -25,7 +25,7 @@ from . import logger, babel
|
||||
if TYPE_CHECKING:
|
||||
from .cog import RoleMenuCog
|
||||
|
||||
_p = babel._p
|
||||
_p, _np = babel._p, babel._np
|
||||
|
||||
MISSING = object()
|
||||
|
||||
@@ -192,6 +192,20 @@ class RoleMenu:
|
||||
self._message = _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):
|
||||
emoji_map = {}
|
||||
for mrole in self.roles:
|
||||
@@ -454,100 +468,252 @@ class RoleMenu:
|
||||
if emojikey(emoji) not in menu_emojis:
|
||||
yield str(emoji)
|
||||
|
||||
async def _handle_selection(self, lion, member: discord.Member, menuroleid: int):
|
||||
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
|
||||
|
||||
async def _handle_positive(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 role is None:
|
||||
# This role no longer exists, nothing we can do
|
||||
if not role:
|
||||
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(
|
||||
t(_p(
|
||||
'rolemenu|error:role_gone',
|
||||
"This role no longer exists!"
|
||||
'rolemenu|select|error:perms',
|
||||
"I don't have enough permissions to give you this role!"
|
||||
))
|
||||
)
|
||||
if role in member.roles:
|
||||
# Member already has the role, deselection case.
|
||||
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"
|
||||
except discord.HTTPException:
|
||||
raise UserInputError(
|
||||
t(_p(
|
||||
'rolemenu|select|error:discord',
|
||||
"An unknown error occurred while assigning your role! "
|
||||
"Please try again later."
|
||||
))
|
||||
)
|
||||
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
|
||||
|
||||
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:
|
||||
# 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
|
||||
if required is not None:
|
||||
# Check member has the required role
|
||||
@@ -561,118 +727,12 @@ class RoleMenu:
|
||||
)).format(role=name)
|
||||
)
|
||||
|
||||
obtainable = self.config.obtainable.value
|
||||
if obtainable is not None:
|
||||
# Check shared roles
|
||||
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
|
||||
if role in member.roles:
|
||||
# Member already has the role, deselection case.
|
||||
return await self._handle_negative(lion, member, mrole)
|
||||
else:
|
||||
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 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
|
||||
# Member does not have the role, selection case.
|
||||
return await self._handle_positive(lion, member, mrole)
|
||||
|
||||
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.page_block = blocks[self.pagen]
|
||||
await self.menu.fetch_message()
|
||||
await self.menu.update_raw()
|
||||
|
||||
@@ -412,7 +412,7 @@ class RoomCog(LionCog):
|
||||
t(_p(
|
||||
'cmd:room_rent|error:member_not_found',
|
||||
"Could not find the requested member {mention} in this server!"
|
||||
)).format(member=f"<@{mid}>")
|
||||
)).format(mention=f"<@{mid}>")
|
||||
), ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
@@ -29,7 +29,7 @@ from .settings import ScheduleSettings, ScheduleConfig
|
||||
from .ui.scheduleui import ScheduleUI
|
||||
from .ui.settingui import ScheduleSettingUI
|
||||
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
|
||||
|
||||
@@ -243,6 +243,18 @@ class ScheduleCog(LionCog):
|
||||
logger.debug(f"Getting slotlock <slotid: {slotid}> (locked: {lock.locked()})")
|
||||
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')
|
||||
async def cancel_bookings(self, *bookingids: tuple[int, int, int], refund=True):
|
||||
"""
|
||||
@@ -415,7 +427,10 @@ class ScheduleCog(LionCog):
|
||||
tasks = []
|
||||
for (gid, uid), member in to_blacklist.items():
|
||||
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)
|
||||
# TODO: Logging and some error handling
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -729,12 +744,29 @@ class ScheduleCog(LionCog):
|
||||
"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
|
||||
async def schedule_cmd(self, ctx: LionContext):
|
||||
# TODO: Auotocomplete for book and cancel options
|
||||
# Will require TTL caching for member schedules.
|
||||
book = None
|
||||
cancel = None
|
||||
async def schedule_cmd(self, ctx: LionContext,
|
||||
cancel: Optional[str] = None,
|
||||
book: Optional[str] = None,
|
||||
):
|
||||
if not ctx.guild:
|
||||
return
|
||||
if not ctx.interaction:
|
||||
@@ -747,6 +779,9 @@ class ScheduleCog(LionCog):
|
||||
now = utc_now()
|
||||
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:
|
||||
schedule = await self._fetch_schedule(ctx.author.id)
|
||||
# Validate provided
|
||||
@@ -756,7 +791,7 @@ class ScheduleCog(LionCog):
|
||||
'cmd:schedule|cancel_booking|error:parse_slot',
|
||||
"Time slot `{provided}` not recognised. "
|
||||
"Please select a session to cancel from the autocomplete options."
|
||||
))
|
||||
)).format(provided=cancel)
|
||||
line = (True, error)
|
||||
elif (slotid := int(cancel)) not in schedule:
|
||||
# Can't cancel slot because it isn't booked
|
||||
@@ -799,8 +834,8 @@ class ScheduleCog(LionCog):
|
||||
'cmd:schedule|create_booking|error:parse_slot',
|
||||
"Time slot `{provided}` not recognised. "
|
||||
"Please select a session to cancel from the autocomplete options."
|
||||
))
|
||||
lines = (True, error)
|
||||
)).format(provided=book)
|
||||
line = (True, error)
|
||||
elif (slotid := int(book)) in schedule:
|
||||
# Can't book because the slot is already booked
|
||||
error = t(_p(
|
||||
@@ -809,7 +844,7 @@ class ScheduleCog(LionCog):
|
||||
)).format(
|
||||
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:
|
||||
# Can't book because it is running or about to start
|
||||
error = t(_p(
|
||||
@@ -823,7 +858,7 @@ class ScheduleCog(LionCog):
|
||||
# The slotid is valid and bookable
|
||||
# Run the booking
|
||||
try:
|
||||
await self.create_booking(guildid, ctx.author.id)
|
||||
await self.create_booking(guildid, ctx.author.id, slotid)
|
||||
ack = t(_p(
|
||||
'cmd:schedule|create_booking|success',
|
||||
"You have successfully scheduled a session at {time}."
|
||||
@@ -856,6 +891,155 @@ class ScheduleCog(LionCog):
|
||||
await ui.run(ctx.interaction)
|
||||
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):
|
||||
"""
|
||||
Fetch the given user's schedule (i.e. booking map)
|
||||
@@ -866,6 +1050,7 @@ class ScheduleCog(LionCog):
|
||||
bookings = await booking_model.fetch_where(
|
||||
booking_model.slotid >= nowid,
|
||||
userid=userid,
|
||||
**kwargs
|
||||
).order_by('slotid', ORDER.ASC)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from random import random
|
||||
from typing import Optional
|
||||
import datetime as dt
|
||||
import asyncio
|
||||
@@ -11,7 +12,7 @@ from utils.lib import MessageArgs
|
||||
|
||||
from .. import babel, logger
|
||||
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 ScheduleConfig
|
||||
from ..ui.sessionui import SessionUI
|
||||
@@ -288,12 +289,16 @@ class ScheduledSession:
|
||||
Remove overwrites for non-members.
|
||||
"""
|
||||
async with self.lock:
|
||||
if not (members := list(self.members.values())):
|
||||
return
|
||||
if not (guild := self.guild):
|
||||
return
|
||||
if not (room := self.room_channel):
|
||||
# Nothing to do
|
||||
self.prepared = True
|
||||
self.opened = True
|
||||
return
|
||||
members = list(self.members.values())
|
||||
|
||||
t = self.bot.translator.t
|
||||
|
||||
if room.permissions_for(guild.me) >= my_room_permissions:
|
||||
# Replace the member overwrites
|
||||
@@ -313,17 +318,36 @@ class ScheduledSession:
|
||||
if mobj:
|
||||
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
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:
|
||||
logger.exception(
|
||||
f"Unhandled discord exception received while opening schedule session room {self!r}"
|
||||
)
|
||||
else:
|
||||
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:
|
||||
t = self.bot.translator.t
|
||||
await self.send(
|
||||
t(_p(
|
||||
'session|open|error:room_permissions',
|
||||
@@ -335,20 +359,107 @@ class ScheduledSession:
|
||||
self.opened = True
|
||||
|
||||
@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.
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(wait)
|
||||
await asyncio.sleep(ping_wait)
|
||||
except asyncio.CancelledError:
|
||||
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]
|
||||
if missing:
|
||||
ping = ''.join(f"<@{mid}>" for mid in missing)
|
||||
message = await self.send(ping)
|
||||
if message is not None:
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -19,7 +19,7 @@ from modules.economy.data import EconomyData, TransactionType
|
||||
|
||||
from .. import babel, logger
|
||||
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 .session import ScheduledSession
|
||||
@@ -439,6 +439,75 @@ class TimeSlot:
|
||||
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:
|
||||
self.run_task = asyncio.create_task(self.run(), name=f"TimeSlot {self.slotid}")
|
||||
return self.run_task
|
||||
@@ -475,6 +544,9 @@ class TimeSlot:
|
||||
logger.info(f"Active timeslot closing. {self!r}")
|
||||
await self.close(list(self.sessions.values()), consequences=True)
|
||||
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:
|
||||
logger.info(
|
||||
f"Deactivating active time slot: {self!r}"
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import asyncio
|
||||
import itertools
|
||||
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 . import logger, babel
|
||||
|
||||
_p, _np = babel._p, babel._np
|
||||
|
||||
|
||||
def time_to_slotid(time: dt.datetime) -> int:
|
||||
@@ -71,3 +77,39 @@ async def limit_concurrency(aws, limit):
|
||||
while done:
|
||||
yield done.pop()
|
||||
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 = 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):
|
||||
@ScheduleConfig.register_model_setting
|
||||
@@ -400,6 +432,7 @@ class ScheduleSettings(SettingGroup):
|
||||
"Minimum attendance must be an integer number of minutes between `1` and `60`."
|
||||
))
|
||||
raise UserInputError(error)
|
||||
return num
|
||||
|
||||
@ScheduleConfig.register_model_setting
|
||||
class BlacklistRole(ModelData, RoleSetting):
|
||||
|
||||
@@ -18,7 +18,7 @@ from .scheduleui import ScheduleUI
|
||||
if TYPE_CHECKING:
|
||||
from ..cog import ScheduleCog
|
||||
|
||||
_p = babel._p
|
||||
_p, _np = babel._p, babel._np
|
||||
|
||||
|
||||
class SessionUI(LeoUI):
|
||||
@@ -59,16 +59,21 @@ class SessionUI(LeoUI):
|
||||
'ui:sessionui|button:schedule|label',
|
||||
'Open Schedule'
|
||||
), locale)
|
||||
self.help_button.label = t(_p(
|
||||
'ui:sessionui|button:help|label',
|
||||
"How to Attend"
|
||||
))
|
||||
|
||||
# ----- API -----
|
||||
async def reload(self):
|
||||
await self.init_components()
|
||||
if self.starting_soon:
|
||||
# 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:
|
||||
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 -----
|
||||
@@ -178,3 +183,141 @@ class SessionUI(LeoUI):
|
||||
ui = ScheduleUI(self.bot, press.guild, press.user.id)
|
||||
await ui.run(press)
|
||||
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."
|
||||
)
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def me_cmd(self, ctx: LionContext):
|
||||
await ctx.interaction.response.defer(thinking=True)
|
||||
ui = ProfileUI(self.bot, ctx.author, ctx.guild)
|
||||
@@ -59,6 +60,7 @@ class StatsCog(LionCog):
|
||||
"Weekly and monthly statistics for your recent activity."
|
||||
)
|
||||
)
|
||||
@appcmds.guild_only
|
||||
async def stats_cmd(self, ctx: LionContext):
|
||||
"""
|
||||
Statistics command.
|
||||
|
||||
@@ -126,7 +126,7 @@ class StatsData(Registry):
|
||||
|
||||
@classmethod
|
||||
@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:
|
||||
raise ValueError('Not enough block points given!')
|
||||
|
||||
@@ -165,8 +165,8 @@ class StatsData(Registry):
|
||||
return (await cursor.fetchone()[0]) or 0
|
||||
|
||||
@classmethod
|
||||
@log_wrap(action='study_times_between')
|
||||
async def study_times_since(cls, guildid: int, userid: int, *starts) -> int:
|
||||
@log_wrap(action='study_times_since')
|
||||
async def study_times_since(cls, guildid: Optional[int], userid: int, *starts) -> int:
|
||||
if len(starts) < 1:
|
||||
raise ValueError('No starting points given!')
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int):
|
||||
else:
|
||||
next_rank = None
|
||||
|
||||
achievements = (0, 1)
|
||||
achievements = (0, 1, 2, 3)
|
||||
|
||||
card = ProfileCard(
|
||||
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
|
||||
if mode in (CardMode.STUDY, CardMode.VOICE):
|
||||
model = data.VoiceSessionStats
|
||||
refkey = (guildid, userid)
|
||||
refkey = (guildid or None, userid)
|
||||
ref_since = model.study_times_since
|
||||
ref_between = model.study_times_between
|
||||
elif mode is CardMode.TEXT:
|
||||
|
||||
@@ -310,6 +310,22 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
||||
"""
|
||||
name = self.display_name
|
||||
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}
|
||||
|
||||
@property
|
||||
@@ -341,14 +357,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
||||
embed.description = "{}\n{}".format(self.long_desc.format(self=self), self.desc_table)
|
||||
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
|
||||
lines = []
|
||||
|
||||
# Currently line
|
||||
lines.append((
|
||||
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
|
||||
@@ -380,7 +396,7 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
|
||||
return TextInput(
|
||||
label=self.display_name,
|
||||
placeholder=self.accepts,
|
||||
default=self.input_formatted,
|
||||
default=self.input_formatted[:4000],
|
||||
required=self._required
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +148,12 @@ class TextTrackerCog(LionCog):
|
||||
logger.info(
|
||||
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
|
||||
lguilds = await self.bot.core.lions.fetch_guilds(*{session.guildid for session in batch})
|
||||
@@ -223,6 +229,7 @@ class TextTrackerCog(LionCog):
|
||||
channel.category_id
|
||||
except discord.ClientException:
|
||||
logger.debug(f"Ignoring message from channel with no parent: {message.channel}")
|
||||
return
|
||||
|
||||
# Untracked channel ward
|
||||
untracked = self.untracked_channels.get(guildid, [])
|
||||
|
||||
@@ -105,12 +105,15 @@ class TextSession:
|
||||
"""
|
||||
Process a message into the session.
|
||||
"""
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
if (message.author.id != self.userid) or (message.guild.id != self.guildid):
|
||||
raise ValueError("Invalid attempt to process message from a different member!")
|
||||
|
||||
# Identify if we need to start a new period
|
||||
tdiff = (message.created_at - self.this_period_start).total_seconds()
|
||||
if self.this_period_start is not None and tdiff < self.period_length:
|
||||
start = self.this_period_start
|
||||
if start is not None and (message.created_at - start).total_seconds() < self.period_length:
|
||||
self.this_period_messages += 1
|
||||
self.this_period_words += len(message.content.split())
|
||||
else:
|
||||
|
||||
@@ -79,6 +79,18 @@ class VoiceTrackerCog(LionCog):
|
||||
"""
|
||||
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')
|
||||
@log_wrap(action='Init Voice Sessions')
|
||||
async def initialise(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Some useful pre-built Conditions for data queries.
|
||||
"""
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
from itertools import chain
|
||||
|
||||
from psycopg import sql
|
||||
@@ -11,7 +11,7 @@ from data.base import Expression
|
||||
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.
|
||||
|
||||
@@ -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.columns = columns
|
||||
self.types = types
|
||||
|
||||
@@ -4,6 +4,7 @@ import datetime
|
||||
import iso8601 # type: ignore
|
||||
import pytz
|
||||
import re
|
||||
import json
|
||||
from contextvars import Context
|
||||
|
||||
import discord
|
||||
@@ -764,7 +765,7 @@ class Timezoned:
|
||||
Return the start of the current month in the object's timezone
|
||||
"""
|
||||
today = self.today
|
||||
return today - datetime.timedelta(days=today.day)
|
||||
return today - datetime.timedelta(days=(today.day - 1))
|
||||
|
||||
|
||||
def replace_multiple(format_string, mapping):
|
||||
@@ -829,3 +830,60 @@ async def check_dm(user: discord.User | discord.Member) -> bool:
|
||||
return False
|
||||
except discord.HTTPException:
|
||||
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):
|
||||
self._leak()
|
||||
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):
|
||||
if self._level:
|
||||
|
||||
@@ -174,6 +174,31 @@ class MsgEditor(MessageUI):
|
||||
"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 --
|
||||
|
||||
@button(label="BODY_BUTTON_PLACEHOLDER", style=ButtonStyle.blurple)
|
||||
@@ -927,7 +952,7 @@ class MsgEditor(MessageUI):
|
||||
return MessageArgs(**args)
|
||||
|
||||
async def refresh_layout(self):
|
||||
await asyncio.gather(
|
||||
to_refresh = (
|
||||
self.edit_button_refresh(),
|
||||
self.add_embed_button_refresh(),
|
||||
self.body_button_refresh(),
|
||||
@@ -941,12 +966,16 @@ class MsgEditor(MessageUI):
|
||||
self.download_button_refresh(),
|
||||
self.undo_button_refresh(),
|
||||
self.redo_button_refresh(),
|
||||
self.rm_embed_button_refresh(),
|
||||
)
|
||||
await asyncio.gather(*to_refresh)
|
||||
|
||||
if self.history[-1].get('embed', None):
|
||||
self.set_layout(
|
||||
(self.body_button, self.author_button, self.footer_button, self.images_button, self.add_field_button),
|
||||
(self.edit_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),
|
||||
)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user