Merge pull request #55 from StudyLions/rewrite

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

9
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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',

View File

@@ -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:

View File

@@ -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,

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

View File

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

View File

@@ -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()

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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):
"""

View File

@@ -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()

View File

@@ -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

View File

@@ -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 {

View File

@@ -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):
"""

View File

@@ -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}"

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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.

View File

@@ -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!')

View File

@@ -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,

View File

@@ -34,7 +34,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
# Extract the study times for each period
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:

View File

@@ -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
)

View File

@@ -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, [])

View File

@@ -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:

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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: