diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index cfdc6a80..7a62d04e 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -1,6 +1,7 @@ from typing import List, Optional, TYPE_CHECKING import logging import asyncio +from weakref import WeakValueDictionary import discord from discord.utils import MISSING @@ -44,6 +45,8 @@ class LionBot(Bot): self.core: Optional['CoreCog'] = None self.translator = translator + self._locks = WeakValueDictionary() + async def setup_hook(self) -> None: log_context.set(f"APP: {self.application_id}") await self.app_ipc.connect() @@ -81,6 +84,12 @@ class LionBot(Bot): with logging_context(action=f"Dispatch {event_name}"): super().dispatch(event_name, *args, **kwargs) + def idlock(self, snowflakeid): + lock = self._locks.get(snowflakeid, None) + if lock is None: + lock = self._locks[snowflakeid] = asyncio.Lock() + return lock + async def on_ready(self): logger.info( f"Logged in as {self.application.name}\n" diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index 6607ee7e..2adedb6b 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -57,6 +57,11 @@ class ScheduleCog(LionCog): self.session_channels = self.settings.SessionChannels._cache + @property + def nowid(self): + now = utc_now() + return time_to_slotid(now) + async def cog_load(self): await self.data.init() @@ -143,6 +148,7 @@ class ScheduleCog(LionCog): Every hour, starting at start_at, the spawn loop will use `_spawner` to ensure the next slotid has been launched. """ + logger.info(f"Started scheduled session spawner at {start_at}") next_spawn = start_at while True: try: @@ -185,6 +191,7 @@ class ScheduleCog(LionCog): lock = self._slot_locks.get(slotid, None) if lock is None: lock = self._slot_locks[slotid] = asyncio.Lock() + logger.debug(f"Getting slotlock (locked: {lock.locked()})") return lock @log_wrap(action='Cancel Booking') @@ -255,28 +262,19 @@ class ScheduleCog(LionCog): nextsession = nextslot.sessions.get(guildid, None) if nextslot else None nextmember = (userid in nextsession.members) if nextsession else None - unlock = None - try: - if nextmember: - unlock = nextsession.lock - await unlock.acquire() - update = (not nextsession.prepared) - else: - update = True - if update: + if (nextmember is None) or not (nextsession.prepared): + async with self.bot.idlock(room.id): + try: await room.set_permissions(member, overwrite=None) - except discord.HTTPException: - pass - finally: - if unlock is not None: - unlock.release() - elif slot is not None and member is None: - # Should not happen - logger.error( - f"Cancelling booking " - "for active slot " - "but the session member was not found. This should not happen." - ) + except discord.HTTPException: + pass + elif slot is not None and member is None: + # Should not happen + logger.error( + f"Cancelling booking " + "for active slot " + "but the session member was not found. This should not happen." + ) @log_wrap(action='Clear Member Schedule') async def clear_member_schedule(self, guildid, userid, refund=False): @@ -305,6 +303,9 @@ class ScheduleCog(LionCog): blacklists depending on guild settings, and notifies the user. """ + logger.debug( + "Handling TimeSlot noshow for members: {}".format(', '.join(map(str, memberids))) + ) now = utc_now() nowid = time_to_slotid(now) member_model = self.data.ScheduleSessionMember @@ -358,6 +359,9 @@ class ScheduleCog(LionCog): tasks.append(task) # TODO: Logging and some error handling await asyncio.gather(*tasks, return_exceptions=True) + logger.info( + f"Applied scheduled session blacklist to {len(to_blacklist)} missing members." + ) # Now cancel future sessions for members who were not blacklisted and are not currently clocked on to_clear = [] @@ -380,7 +384,12 @@ class ScheduleCog(LionCog): bookingids = [(b.slotid, b.guildid, b.userid) for b in bookings] if bookingids: await self.cancel_bookings(*bookingids, refund=False) - # TODO: Logging and error handling + logger.info( + f"Cancelled future sessions for {len(to_clear)} missing members." + ) + logger.debug( + "Completed NoShow handling" + ) @log_wrap(action='Create Booking') async def create_booking(self, guildid, userid, *slotids): @@ -390,77 +399,80 @@ class ScheduleCog(LionCog): Probably best refactored into an interactive method, with some parts in slot and session. """ + logger.debug( + f"Creating bookings for member in " + f"for slotids: {', '.join(map(str, slotids))}" + ) t = self.bot.translator.t locks = [self.slotlock(slotid) for slotid in slotids] await asyncio.gather(*(lock.acquire() for lock in locks)) try: - conn = await self.bot.db.get_connection() - async with conn.transaction(): - # Validate bookings - guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid) - config = ScheduleConfig(guildid, guild_data) + # Validate bookings + guild_data = await self.data.ScheduleGuild.fetch_or_create(guildid) + config = ScheduleConfig(guildid, guild_data) - # Check guild lobby exists - if config.get(ScheduleSettings.SessionLobby.setting_id).value is None: - error = t(_p( - 'create_booking|error:no_lobby', - "This server has not set a `session_lobby`, so the scheduled session system is disabled!" - )) - raise UserInputError(error) + # Check guild lobby exists + if config.get(ScheduleSettings.SessionLobby.setting_id).value is None: + error = t(_p( + 'create_booking|error:no_lobby', + "This server has not set a `session_lobby`, so the scheduled session system is disabled!" + )) + raise UserInputError(error) - # Fetch up to data lion data and member data - lion = await self.bot.core.lions.fetch_member(guildid, userid) - member = await lion.fetch_member() - await lion.data.refresh() - if not member: - # This should pretty much never happen unless something went wrong on Discord's end - error = t(_p( - 'create_booking|error:no_member', - "An unknown Discord error occurred. Please try again in a few minutes." - )) - raise UserInputError(error) + # Fetch up to data lion data and member data + lion = await self.bot.core.lions.fetch_member(guildid, userid) + member = await lion.fetch_member() + await lion.data.refresh() + if not member: + # This should pretty much never happen unless something went wrong on Discord's end + error = t(_p( + 'create_booking|error:no_member', + "An unknown Discord error occurred. Please try again in a few minutes." + )) + raise UserInputError(error) - # Check member blacklist - if (role := config.get(ScheduleSettings.BlacklistRole.setting_id).value) and role in member.roles: - error = t(_p( - 'create_booking|error:blacklisted', - "You have been blacklisted from the scheduled session system in this server." - )) - raise UserInputError(error) + # Check member blacklist + if (role := config.get(ScheduleSettings.BlacklistRole.setting_id).value) and role in member.roles: + error = t(_p( + 'create_booking|error:blacklisted', + "You have been blacklisted from the scheduled session system in this server." + )) + raise UserInputError(error) - # Check member balance - requested = len(slotids) - required = len(slotids) * config.get(ScheduleSettings.ScheduleCost.setting_id).value - balance = lion.data.coins - if balance < required: - error = t(_np( - 'create_booking|error:insufficient_balance', - "Booking a session costs {coin}**{required}**, but you only have {coin}**{balance}**.", - "Booking `{count}` sessions costs {coin}**{required}**, but you only have {coin}**{balance}**.", - requested - )).format( - count=requested, coin=self.bot.config.emojis.coin, - required=required, balance=balance - ) - raise UserInputError(error) - - # Check existing bookings - schedule = await self._fetch_schedule(userid) - if set(slotids).intersection(schedule.keys()): - error = t(_p( - 'create_booking|error:already_booked', - "One or more requested timeslots are already booked!" - )) - raise UserInputError(error) - - # Booking request is now validated. Perform bookings. - - # Fetch or create session data - await self.data.ScheduleSlot.fetch_multiple(*slotids) - session_data = await self.data.ScheduleSession.fetch_multiple( - *((guildid, slotid) for slotid in slotids) + # Check member balance + requested = len(slotids) + required = len(slotids) * config.get(ScheduleSettings.ScheduleCost.setting_id).value + balance = lion.data.coins + if balance < required: + error = t(_np( + 'create_booking|error:insufficient_balance', + "Booking a session costs {coin}**{required}**, but you only have {coin}**{balance}**.", + "Booking `{count}` sessions costs {coin}**{required}**, but you only have {coin}**{balance}**.", + requested + )).format( + count=requested, coin=self.bot.config.emojis.coin, + required=required, balance=balance ) + raise UserInputError(error) + # Check existing bookings + schedule = await self._fetch_schedule(userid) + if set(slotids).intersection(schedule.keys()): + error = t(_p( + 'create_booking|error:already_booked', + "One or more requested timeslots are already booked!" + )) + raise UserInputError(error) + conn = await self.bot.db.get_connection() + + # Booking request is now validated. Perform bookings. + # Fetch or create session data + await self.data.ScheduleSlot.fetch_multiple(*slotids) + session_data = await self.data.ScheduleSession.fetch_multiple( + *((guildid, slotid) for slotid in slotids) + ) + + async with conn.transaction(): # Create transactions economy = self.bot.get_cog('Economy') trans_data = ( @@ -482,42 +494,56 @@ class ScheduleCog(LionCog): ) ) - # Now pass to activated slots - for record in booking_data: - slotid = record['slotid'] - if (slot := self.active_slots.get(slotid, None)): - session = slot.sessions.get(guildid, None) - if session is None: - # Create a new session in the slot and set it up - sessions = await slot.load_sessions([session_data[guildid, slotid]]) - session = sessions[guildid] - slot.sessions[guildid] = session - if slot.closing.is_set(): - # This should never happen - logger.error( - "Attempt to book a session in a closing slot. This should be impossible." - ) - raise ValueError('Cannot book a session in a closing slot.') - elif slot.opening.is_set(): - await slot.open([session]) - elif slot.preparing.is_set(): - await slot.prepare([session]) - else: - # Session already exists in the slot - async with session.lock: - if session.prepared: - session.update_status_soon() - if (room := session.room_channel) and (mem := session.guild.get_member(userid)): - try: - await room.set_permissions( - mem, connect=True, view_channel=True - ) - except discord.HTTPException: - pass + # Now pass to activated slots + for record in booking_data: + slotid = record['slotid'] + if (slot := self.active_slots.get(slotid, None)): + session = slot.sessions.get(guildid, None) + if session is None: + # Create a new session in the slot and set it up + sessions = await slot.load_sessions([session_data[guildid, slotid]]) + session = sessions[guildid] + slot.sessions[guildid] = session + if slot.closing.is_set(): + # This should never happen + logger.error( + "Attempt to book a session in a closing slot. This should be impossible." + ) + raise ValueError('Cannot book a session in a closing slot.') + elif slot.opening.is_set(): + await slot.open([session]) + elif slot.preparing.is_set(): + await slot.prepare([session]) + else: + # Session already exists in the slot + async with session.lock: + if session.prepared: + session.update_status_soon() + if (room := session.room_channel) and (mem := session.guild.get_member(userid)): + try: + await room.set_permissions( + mem, connect=True, view_channel=True + ) + except discord.HTTPException: + logger.info( + f"Could not set room permissions for newly booked session " + f" in {session!r}", + exc_info=True + ) + logger.info( + f"Member in booked scheduled sessions: " + + ', '.join(map(str, slotids)) + ) + except UserInputError: + raise + except Exception: + logger.exception( + "Unexpected exception occurred while booking scheduled sessions." + ) + raise finally: for lock in locks: lock.release() - # TODO: Logging and error handling return booking_data # Event listeners @@ -592,7 +618,7 @@ class ScheduleCog(LionCog): member.clock_on(session_data.start_time) session.update_status_soon() logger.debug( - f"Clocked on member {member.data!r} with session {session_data!r}" + f"Clocked on member {member.data!r} in session {session!r}" ) except Exception: logger.exception( @@ -616,11 +642,11 @@ class ScheduleCog(LionCog): member = session.members.get(session_data.userid, None) if session else None if member is not None: async with session.lock: - if session.listening and session.validate_channel(session_data.channelid): + if session.listening and member.clock_start is not None: member.clock_off(ended_at) session.update_status_soon() logger.debug( - f"Clocked off member {member.data!r} from session {session_data!r}" + f"Clocked off member {member.data!r} from session {session!r}" ) except Exception: logger.exception( diff --git a/src/modules/schedule/core/session.py b/src/modules/schedule/core/session.py index 95fb0688..e4fde402 100644 --- a/src/modules/schedule/core/session.py +++ b/src/modules/schedule/core/session.py @@ -80,6 +80,26 @@ class ScheduledSession: self._updater = None self._status_task = None + def __repr__(self): + return ' '.join( + "" + ) + # Setting shortcuts @property def room_channel(self) -> Optional[discord.VoiceChannel]: @@ -185,7 +205,7 @@ class ScheduledSession: self._hook = None except discord.HTTPException: logger.warning( - f"Exception occurred sending to webhooks for scheduled session {self.data!r}", + f"Exception occurred sending to webhooks for scheduled session {self!r}", exc_info=True ) @@ -221,14 +241,12 @@ class ScheduledSession: await room.edit(overwrites=overwrites) except discord.HTTPException: logger.warning( - f"Unexpected discord exception received while preparing schedule session room " - f"in guild for timeslot .", + f"Unexpected discord exception received while preparing schedule session room {self!r}", exc_info=True ) else: logger.debug( - f"Prepared schedule session room " - f"in guild for timeslot .", + f"Prepared schedule session room for session {self!r}" ) else: t = self.bot.translator.t @@ -266,13 +284,11 @@ class ScheduledSession: await room.edit(overwrites=overwrites) except discord.HTTPException: logger.exception( - f"Unhandled discord exception received while opening schedule session room " - f"in guild for timeslot ." + f"Unhandled discord exception received while opening schedule session room {self!r}" ) else: logger.debug( - f"Opened schedule session room " - f"in guild for timeslot .", + f"Opened schedule session room for session {self!r}" ) else: t = self.bot.translator.t @@ -485,7 +501,7 @@ class ScheduledSession: except discord.HTTPException: # Unexpected issue updating the message logger.exception( - f"Exception occurred updating status for scheduled session {self.data!r}" + f"Exception occurred updating status for scheduled session {self!r}" ) if repost and resend and self.members: @@ -531,12 +547,11 @@ class ScheduledSession: await self.update_status() except asyncio.CancelledError: logger.debug( - f"Cancelled scheduled session update loop ,gid: {self.guildid}>" + f"Cancelled scheduled session update loop for session {self!r}" ) except Exception: logger.exception( - "Unknown exception encountered during session " - f"update loop ,gid: {self.guildid}>" + "Unknown exception encountered during session update loop for session {self!r} " ) def start_updating(self): diff --git a/src/modules/schedule/core/timeslot.py b/src/modules/schedule/core/timeslot.py index 42f8173f..5a0b5a70 100644 --- a/src/modules/schedule/core/timeslot.py +++ b/src/modules/schedule/core/timeslot.py @@ -13,12 +13,13 @@ from core.lion_member import LionMember from core.lion_guild import LionGuild from tracking.voice.session import SessionState from utils.data import as_duration, MEMBERS, TemporaryTable +from utils.ratelimits import Bucket from modules.economy.cog import Economy 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 +from ..lib import slotid_to_utc, batchrun_per_second, limit_concurrency from ..settings import ScheduleSettings from .session import ScheduledSession @@ -63,6 +64,41 @@ class TimeSlot: self.run_task = None self.loaded = False + def __repr__(self): + if self.closing.is_set(): + state = 'closing' + elif self.opened.is_set(): + state = 'opened' + elif self.opening.is_set(): + state = 'opening' + elif self.preparing.is_set(): + state = 'preparing' + elif self.loaded: + state = 'loaded' + else: + state = 'unloaded' + + if self.run_task: + if self.run_task.cancelled(): + running = 'Cancelled' + elif self.run_task.done(): + running = 'Done' + else: + running = 'Running' + else: + running = 'None' + + return ( + "" + ) + @log_wrap(action="Fetch sessions") async def fetch(self): """ @@ -81,7 +117,7 @@ class TimeSlot: self.sessions.update(sessions) self.loaded = True logger.info( - f"Timeslot finished preloading {len(self.sessions)} guilds. Ready to open." + f"Timeslot {self!r}> finished preloading {len(self.sessions)} guilds. Ready to open." ) @log_wrap(action="Load sessions") @@ -129,7 +165,7 @@ class TimeSlot: sessions[row.guildid] = session logger.debug( - f"Timeslot " + f"Timeslot {self!r} " f"loaded guild data for {len(sessions)} guilds: {', '.join(map(str, guildids))}" ) return sessions @@ -204,10 +240,12 @@ class TimeSlot: This does not take the session lock for setting perms, because this is race-safe (aside from potentially leaving extra permissions, which will be overwritten by `open`). """ - logger.debug(f"Running prepare for time slot with {len(sessions)} sessions.") + logger.debug(f"Running prepare for time slot: {self!r}") try: - coros = [session.prepare(save=False) for session in sessions if session.can_run] - await batchrun_per_second(coros, 5) + bucket = Bucket(5, 1) + coros = [bucket.wrapped(session.prepare(save=False)) for session in sessions if session.can_run] + async for task in limit_concurrency(coros, 5): + await task # Save messageids tmptable = TemporaryTable( @@ -227,11 +265,11 @@ class TimeSlot: ).from_expr(tmptable) except Exception: logger.exception( - f"Unhandled exception while preparing timeslot ." + f"Unhandled exception while preparing timeslot: {self!r}" ) else: logger.info( - f"Prepared {len(sessions)} for scheduled session timeslot " + f"Prepared {len(sessions)} for scheduled session timeslot: {self!r}" ) @log_wrap(action="Open Sessions") @@ -269,12 +307,14 @@ class TimeSlot: session.start_updating() # Bulk run guild open to open session rooms + bucket = Bucket(5, 1) voice_coros = [ - session.open_room() + bucket.wrapped(session.open_room()) for session in fresh if session.room_channel is not None and session.data.opened_at is None ] - await batchrun_per_second(voice_coros, 5) + async for task in limit_concurrency(voice_coros, 5): + await task await asyncio.gather(*message_tasks) await asyncio.gather(*notify_tasks) @@ -297,11 +337,11 @@ class TimeSlot: ).from_expr(tmptable) except Exception: logger.exception( - f"Unhandled exception while opening sessions for timeslot ." + f"Unhandled exception while opening sessions for timeslot: {self!r}" ) else: logger.info( - f"Opened {len(sessions)} sessions for scheduled session timeslot " + f"Opened {len(sessions)} sessions for scheduled session timeslot: {self!r}" ) @log_wrap(action="Close Sessions") @@ -394,11 +434,11 @@ class TimeSlot: await self.cog.handle_noshow(*did_not_show) except Exception: logger.exception( - f"Unhandled exception while closing sessions for timeslot ." + f"Unhandled exception while closing sessions for timeslot: {self!r}" ) else: logger.info( - f"Closed {len(sessions)} for scheduled session timeslot " + f"Closed {len(sessions)} for scheduled session timeslot: {self!r}" ) def launch(self) -> asyncio.Task: @@ -420,32 +460,30 @@ class TimeSlot: if now < self.start_at: await discord.utils.sleep_until(self.prep_at) self.preparing.set() + logger.info(f"Active timeslot preparing. {self!r}") await self.prepare(list(self.sessions.values())) - else: + logger.info(f"Active timeslot prepared. {self!r}") await discord.utils.sleep_until(self.start_at) + else: self.preparing.set() + self.opening.set() + logger.info(f"Active timeslot opening. {self!r}") await self.open(list(self.sessions.values())) + logger.info(f"Active timeslot opened. {self!r}") self.opened.set() await discord.utils.sleep_until(self.end_at) self.closing.set() + 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}") except asyncio.CancelledError: - if self.closing.is_set(): - state = 'closing' - elif self.opened.is_set(): - state = 'opened' - elif self.opening.is_set(): - state = 'opening' - elif self.preparing.is_set(): - state = 'preparing' logger.info( - f"Deactivating active time slot " - f"with state '{state}'." + f"Deactivating active time slot: {self!r}" ) except Exception: logger.exception( - f"Unexpected exception occurred while running active time slot ." + f"Unexpected exception occurred while running active time slot: {self!r}." ) @log_wrap(action="Slot Cleanup") diff --git a/src/modules/schedule/lib.py b/src/modules/schedule/lib.py index 65e2d997..1eb8b588 100644 --- a/src/modules/schedule/lib.py +++ b/src/modules/schedule/lib.py @@ -2,6 +2,7 @@ import asyncio import itertools import datetime as dt +from . import logger from utils.ratelimits import Bucket @@ -39,3 +40,34 @@ async def batchrun_per_second(awaitables, batchsize): task = asyncio.create_task(awaitable) task.add_done_callback(lambda fut: sem.release()) return await asyncio.gather(*tasks, return_exceptions=True) + + +async def limit_concurrency(aws, limit): + """ + Run provided awaitables concurrently, + ensuring that no more than `limit` are running at once. + """ + aws = iter(aws) + aws_ended = False + pending = set() + count = 0 + logger.debug("Starting limited concurrency executor") + + while pending or not aws_ended: + while len(pending) < limit and not aws_ended: + aw = next(aws, None) + if aw is None: + aws_ended = True + else: + pending.add(asyncio.create_task(aw)) + count += 1 + + if not pending: + break + + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + while done: + yield done.pop() + logger.debug(f"Completed {count} tasks") diff --git a/src/modules/schedule/ui/scheduleui.py b/src/modules/schedule/ui/scheduleui.py index 9e409e8d..d0372088 100644 --- a/src/modules/schedule/ui/scheduleui.py +++ b/src/modules/schedule/ui/scheduleui.py @@ -141,8 +141,7 @@ class ScheduleUI(MessageUI): 'ui:schedule|button:clear|label', "Clear Schedule" )) - if not self.schedule: - self.clear_button.disabled = True + self.clear_button.disabled = (not self.schedule) @button(label='ABOUT_PLACEHOLDER', emoji=conf.emojis.question, style=ButtonStyle.grey) async def about_button(self, press: discord.Interaction, pressed: Button): @@ -220,7 +219,7 @@ class ScheduleUI(MessageUI): try: await self.cog.create_booking(self.guildid, self.userid, *slotids) timestrings = [ - discord.utils.format_dt(slotid_to_utc(slotid), style='T') + discord.utils.format_dt(slotid_to_utc(slotid), style='f') for slotid in slotids ] ack = t(_np( @@ -264,6 +263,9 @@ class ScheduleUI(MessageUI): # Populate with choices 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 self.schedule] options = self._format_slot_options(*upcoming) @@ -298,7 +300,7 @@ class ScheduleUI(MessageUI): slot_format = t(_p( 'ui:schedule|menu:slots|option|format', - "{day} {time} (in {until})" + "{day} {time} ({until})" )) today_name = t(_p( 'ui:schedule|menu:slots|option|day:today', @@ -325,12 +327,18 @@ class ScheduleUI(MessageUI): def _format_until(self, distance): t = self.bot.translator.t - return t(_np( - 'ui:schedule|format_until', - "<1 hour", - "{number} hours", - distance - )).format(number=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!" + )) @select(cls=Select, placeholder='CANCEL_MENU_PLACEHOLDER') async def cancel_menu(self, selection: discord.Interaction, selected): @@ -379,7 +387,7 @@ class ScheduleUI(MessageUI): embed = error_embed(error) else: timestrings = [ - discord.utils.format_dt(slotid_to_utc(record['slotid']), style='T') + discord.utils.format_dt(slotid_to_utc(record['slotid']), style='f') for record in booking_records ] ack = t(_np( @@ -407,8 +415,11 @@ class ScheduleUI(MessageUI): 'ui:schedule|menu:cancel|placeholder', "Cancel booked sessions" )) - can_cancel = set(self.schedule.keys()) - can_cancel.discard(self.nowid) + minid = self.nowid + if ((slotid_to_utc(self.nowid + 3600) - utc_now()).total_seconds() < 60): + minid = self.nowid + 3600 + can_cancel = list(slotid for slotid in self.schedule.keys() if slotid > minid) + menu.options = self._format_slot_options(*can_cancel) menu.max_values = len(menu.options) @@ -520,11 +531,11 @@ class ScheduleUI(MessageUI): t = self.bot.translator.t short_format = t(_p( 'ui:schedule|booking_format:short', - "`in {until}` | {start} - {end}" + "`{until}` | {start} - {end}" )) long_format = t(_p( 'ui:schedule|booking_format:long', - "> `in {until}` | {start} - {end}" + "> `{until}` | {start} - {end}" )) items = [] format = long_format if show_guild else short_format diff --git a/src/utils/ratelimits.py b/src/utils/ratelimits.py index a865799c..9dbfdc81 100644 --- a/src/utils/ratelimits.py +++ b/src/utils/ratelimits.py @@ -92,6 +92,11 @@ class Bucket: while self.full: await asyncio.sleep(self.delay) + async def wrapped(self, coro): + await self.wait() + self.request() + await coro + class RateLimit: def __init__(self, max_level, empty_time, error=None, cache=TTLCache(1000, 60 * 60)):