rewrite: New Scheduled Session System.

This commit is contained in:
2023-06-30 11:15:39 +03:00
parent b7d66ffe8c
commit a7c5af59a7
31 changed files with 4588 additions and 44 deletions

View File

@@ -0,0 +1,3 @@
from .session_member import SessionMember
from .session import ScheduledSession
from .timeslot import TimeSlot

View File

@@ -0,0 +1,544 @@
from typing import Optional
import datetime as dt
import asyncio
import discord
from meta import LionBot
from utils.lib import utc_now
from utils.lib import MessageArgs
from .. import babel, logger
from ..data import ScheduleData as Data
from ..lib import slotid_to_utc
from ..settings import ScheduleSettings as Settings
from ..settings import ScheduleConfig
from ..ui.sessionui import SessionUI
from .session_member import SessionMember
_p = babel._p
my_room_permissions = discord.Permissions(
connect=True,
view_channel=True,
manage_roles=True,
manage_permissions=True
)
member_room_permissions = discord.PermissionOverwrite(
connect=True,
view_channel=True
)
class ScheduledSession:
"""
Guild-local context for a scheduled session timeslot.
Manages the status message and member list.
"""
update_interval = 60
max_update_interval = 10
# TODO: Slots
# NOTE: All methods MUST permit the guild or channels randomly vanishing
# NOTE: All methods MUST be robust, and not propagate exceptions
# TODO: Guild locale context
def __init__(self,
bot: LionBot,
data: Data.ScheduleSession, config_data: Data.ScheduleGuild,
session_channels: Settings.SessionChannels):
self.bot = bot
self.data = data
self.slotid = data.slotid
self.guildid = data.guildid
self.config = ScheduleConfig(self.guildid, config_data)
self.channels_setting = session_channels
self.starts_at = slotid_to_utc(self.slotid)
self.ends_at = slotid_to_utc(self.slotid + 3600)
# Whether to listen to clock events
# should be set externally after the clocks have been initially set
self.listening = False
# Whether the session has prepared the room and sent the first message
# Also set by open()
self.prepared = False
# Whether the session has set the room permissions
self.opened = False
# Whether this session has been cancelled. Always set externally
self.cancelled = False
self.members: dict[int, SessionMember] = {}
self.lock = asyncio.Lock()
self.status_message = None
self._hook = None # Lobby webhook data
self._warned_hook = False
self._last_update = None
self._updater = None
self._status_task = None
# Setting shortcuts
@property
def room_channel(self) -> Optional[discord.VoiceChannel]:
return self.config.get(Settings.SessionRoom.setting_id).value
@property
def lobby_channel(self) -> Optional[discord.TextChannel]:
return self.config.get(Settings.SessionLobby.setting_id).value
@property
def bonus_reward(self) -> int:
return self.config.get(Settings.AttendanceBonus.setting_id).value
@property
def attended_reward(self) -> int:
return self.config.get(Settings.AttendanceReward.setting_id).value
@property
def min_attendence(self) -> int:
return self.config.get(Settings.MinAttendance.setting_id).value
@property
def all_attended(self) -> bool:
return all(member.total_clock >= self.min_attendence for member in self.members.values())
@property
def can_run(self) -> bool:
"""
Returns True if this session exists and needs to run.
"""
return self.guild and self.members
@property
def messageid(self) -> Optional[int]:
return self.status_message.id if self.status_message else None
@property
def guild(self) -> Optional[discord.Guild]:
return self.bot.get_guild(self.guildid)
def validate_channel(self, channelid) -> bool:
channel = self.bot.get_channel(channelid)
if channel is not None:
channels = self.channels_setting.value
return (not channels) or (channel in channels) or (channel.category and (channel.category in channels))
else:
return False
async def get_lobby_hook(self) -> Optional[discord.Webhook]:
"""
Fetch or create the webhook in the scheduled session lobby
"""
channel = self.lobby_channel
if channel:
cid = channel.id
if self._hook and self._hook.channelid == cid:
hook = self._hook
else:
hook = self._hook = await self.bot.core.data.LionHook.fetch(cid)
if not hook:
# Attempt to create
try:
if channel.permissions_for(channel.guild.me).manage_webhooks:
avatar = self.bot.user.avatar
avatar_data = (await avatar.to_file()).fp.read() if avatar else None
webhook = await channel.create_webhook(
avatar=avatar_data,
name=f"{self.bot.user.name} Scheduled Sessions",
reason="Scheduled Session Lobby"
)
hook = await self.bot.core.data.LionHook.create(
channelid=cid,
token=webhook.token,
webhookid=webhook.id
)
elif channel.permissions_for(channel.guild.me).send_messages and not self._warned_hook:
t = self.bot.translator.t
self._warned_hook = True
await channel.send(
t(_p(
'session|error:lobby_webhook_perms',
"Insufficient permissions to create a webhook in this channel. "
"I require the `MANAGE_WEBHOOKS` permission."
))
)
except discord.HTTPException:
logger.warning(
"Unexpected Exception occurred while creating scheduled session lobby webhook.",
exc_info=True
)
if hook:
return hook.as_webhook(client=self.bot)
async def send(self, *args, wait=True, **kwargs):
lobby_hook = await self.get_lobby_hook()
if lobby_hook:
try:
return await lobby_hook.send(*args, wait=wait, **kwargs)
except discord.NotFound:
# Webhook was deleted under us
if self._hook is not None:
await self._hook.delete()
self._hook = None
except discord.HTTPException:
logger.warning(
f"Exception occurred sending to webhooks for scheduled session {self.data!r}",
exc_info=True
)
async def prepare(self, **kwargs):
"""
Execute prepare stage for this guild.
"""
async with self.lock:
await self.prepare_room()
await self.update_status(**kwargs)
self.prepared = True
async def prepare_room(self):
"""
Add overwrites allowing current members to connect.
"""
async with self.lock:
if not (members := list(self.members.values())):
return
if not (guild := self.guild):
return
if not (room := self.room_channel):
return
if room.permissions_for(guild.me) >= my_room_permissions:
# Add member overwrites
overwrites = room.overwrites
for member in members:
mobj = guild.get_member(member.userid)
if mobj:
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
try:
await room.edit(overwrites=overwrites)
except discord.HTTPException:
logger.warning(
f"Unexpected discord exception received while preparing schedule session room <cid: {room.id}> "
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
exc_info=True
)
else:
logger.debug(
f"Prepared schedule session room <cid: {room.id}> "
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
)
else:
t = self.bot.translator.t
await self.send(
t(_p(
'session|prepare|error:room_permissions',
f"Could not prepare the configured session room {room} for the next scheduled session! "
"I require the `MANAGE_CHANNEL`, `MANAGE_ROLES`, `CONNECT` and `VIEW_CHANNEL` permissions."
)).format(room=room.mention)
)
async def open_room(self):
"""
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):
return
if room.permissions_for(guild.me) >= my_room_permissions:
# Replace the member overwrites
overwrites = {
target: overwrite for target, overwrite in room.overwrites.items()
if not isinstance(target, discord.Member)
}
for member in members:
mobj = guild.get_member(member.userid)
if mobj:
overwrites[mobj] = discord.PermissionOverwrite(connect=True, view_channel=True)
try:
await room.edit(overwrites=overwrites)
except discord.HTTPException:
logger.exception(
f"Unhandled discord exception received while opening schedule session room <cid: {room.id}> "
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>."
)
else:
logger.debug(
f"Opened schedule session room <cid: {room.id}> "
f"in guild <gid: {self.guildid}> for timeslot <sid: {self.slotid}>.",
)
else:
t = self.bot.translator.t
await self.send(
t(_p(
'session|open|error:room_permissions',
f"Could not set up the configured session room {room} for this scheduled session! "
"I require the `MANAGE_CHANNEL`, `MANAGE_ROLES`, `CONNECT` and `VIEW_CHANNEL` permissions."
)).format(room=room.mention)
)
self.prepared = True
self.opened = True
async def notify(self):
"""
Ghost ping members who have not yet attended.
"""
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())
async def current_status(self) -> MessageArgs:
"""
Lobby status message args.
"""
t = self.bot.translator.t
now = utc_now()
view = SessionUI(self.bot, self.slotid, self.guildid)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'session|status|title',
"Session {start} - {end}"
)).format(
start=discord.utils.format_dt(self.starts_at, 't'),
end=discord.utils.format_dt(self.ends_at, 't'),
)
)
embed.timestamp = now
if self.cancelled:
embed.description = t(_p(
'session|status|desc:cancelled',
"I cancelled this scheduled session because I was unavailable. "
"All members who booked the session have been refunded."
))
view = None
elif not self.members:
embed.description = t(_p(
'session|status|desc:no_members',
"*No members scheduled this session.*"
))
elif now < self.starts_at:
# Preparation stage
embed.description = t(_p(
'session|status:preparing|desc:has_members',
"Starting {start}"
)).format(start=discord.utils.format_dt(self.starts_at, 'R'))
embed.add_field(
name=t(_p('session|status:preparing|field:members', "Members")),
value=', '.join(f"<@{m}>" for m in self.members)
)
elif now < self.starts_at + dt.timedelta(hours=1):
# Running status
embed.description = t(_p(
'session|status:running|desc:has_members',
"Finishing {start}"
)).format(start=discord.utils.format_dt(self.ends_at, 'R'))
missing = []
present = []
min_attendence = self.min_attendence
for mid, member in self.members.items():
clock = int(member.total_clock)
if clock == 0 and member.clock_start is None:
memstr = f"<@{mid}>"
missing.append(memstr)
else:
memstr = "<@{mid}> **({M:02}:{S:02})**".format(
mid=mid,
M=int(clock // 60),
S=int(clock % 60)
)
present.append((memstr, clock, bool(member.clock_start)))
waiting_for = []
attending = []
attended = []
present.sort(key=lambda t: t[1], reverse=True)
for memstr, clock, clocking in present:
if clocking:
attending.append(memstr)
elif clock >= min_attendence:
attended.append(memstr)
else:
waiting_for.append(memstr)
waiting_for.extend(missing)
if waiting_for:
embed.add_field(
name=t(_p('session|status:running|field:waiting', "Waiting For")),
value='\n'.join(waiting_for),
inline=True
)
if attending:
embed.add_field(
name=t(_p('session|status:running|field:attending', "Attending")),
value='\n'.join(attending),
inline=True
)
if attended:
embed.add_field(
name=t(_p('session|status:running|field:attended', "Attended")),
value='\n'.join(attended),
inline=True
)
else:
# Finished, show summary
attended = []
missed = []
min_attendence = self.min_attendence
for mid, member in self.members.items():
clock = int(member.total_clock)
memstr = "<@{mid}> **({M:02}:{S:02})**".format(
mid=mid,
M=int(clock // 60),
S=int(clock % 60)
)
if clock < min_attendence:
missed.append(memstr)
else:
attended.append(memstr)
if not missed:
# Everyone attended
embed.description = t(_p(
'session|status:finished|desc:everyone_att',
"Everyone attended the session! "
"All members were rewarded with {coin} **{reward} + {bonus}**!"
)).format(
coin=self.bot.config.emojis.coin,
reward=self.attended_reward,
bonus=self.bonus_reward
)
elif missed and attended:
# Mix of both
embed.description = t(_p(
'session|status:finished|desc:some_att',
"Everyone who attended was rewarded with {coin} **{reward}**! "
"Some members did not attend so everyone missed out on the bonus {coin} **{bonus}**.\n"
"**Members who missed their session have all future sessions cancelled without refund!*"
)).format(
coin=self.bot.config.emojis.coin,
reward=self.attended_reward,
bonus=self.bonus_reward
)
else:
# No-one attended
embed.description = t(_p(
'session|status:finished|desc:some_att',
"No-one attended this session! No-one received rewards.\n"
"**Members who missed their session have all future sessions cancelled without refund!*"
))
if attended:
embed.add_field(
name=t(_p('session|status:finished|field:attended', "Attended")),
value='\n'.join(attended)
)
if missed:
embed.add_field(
name=t(_p('session|status:finished|field:missing', "Missing")),
value='\n'.join(missed)
)
view = None
if view is not None:
await view.reload()
args = MessageArgs(embed=embed, view=view)
return args
async def _update_status(self, save=True, resend=True):
"""
Send or update the lobby message.
"""
self._last_update = utc_now()
args = await self.current_status()
message = self.status_message
if message is None and self.data.messageid is not None:
lobby_hook = await self.get_lobby_hook()
if lobby_hook:
try:
message = await lobby_hook.fetch_message(self.data.messageid)
except discord.HTTPException:
message = None
repost = message is None
if not repost:
try:
await message.edit(**args.edit_args)
self.status_message = message
except discord.NotFound:
repost = True
self.status_message = None
except discord.HTTPException:
# Unexpected issue updating the message
logger.exception(
f"Exception occurred updating status for scheduled session {self.data!r}"
)
if repost and resend and self.members:
message = await self.send(**args.send_args)
self.status_message = message
if save:
await self.data.update(messageid=message.id if message else None)
async def _update_status_soon(self, **kwargs):
try:
if self._last_update is not None:
next_update = self._last_update + dt.timedelta(seconds=self.max_update_interval)
await discord.utils.sleep_until(next_update)
task = asyncio.create_task(self._update_status(**kwargs))
await asyncio.shield(task)
except asyncio.CancelledError:
pass
def update_status_soon(self, **kwargs):
if self._status_task and not self._status_task.done():
self._status_task.cancel()
self._status_task = asyncio.create_task(self._update_status_soon(**kwargs))
async def update_status(self, **kwargs):
if self._status_task and not self._status_task.done():
self._status_task.cancel()
await self._update_status(**kwargs)
async def update_loop(self):
"""
Keep the lobby message up to date with a message per minute.
Takes into account external and manual updates.
"""
try:
if self._last_update:
await discord.utils.sleep_until(self._last_update + dt.timedelta(seconds=self.update_interval))
while (now := utc_now()) <= self.ends_at:
await self.update_status()
while now < (next_update := (self._last_update + dt.timedelta(seconds=self.update_interval))):
await discord.utils.sleep_until(next_update)
now = utc_now()
await self.update_status()
except asyncio.CancelledError:
logger.debug(
f"Cancelled scheduled session update loop <slotid: {self.slotid}> ,gid: {self.guildid}>"
)
except Exception:
logger.exception(
"Unknown exception encountered during session "
f"update loop <slotid: {self.slotid}> ,gid: {self.guildid}>"
)
def start_updating(self):
self._updater = asyncio.create_task(self.update_loop())
return self._updater

View File

@@ -0,0 +1,69 @@
from typing import Optional
from collections import defaultdict
import datetime as dt
import asyncio
import itertools
import discord
from meta import LionBot
from utils.lib import utc_now
from core.lion_member import LionMember
from .. import babel, logger
from ..data import ScheduleData as Data
from ..lib import slotid_to_utc
_p = babel._p
class SessionMember:
"""
Member context for a scheduled session timeslot.
Intended to keep track of members for ongoing and upcoming sessions.
Primarily used to track clock time and set attended status.
"""
# TODO: slots
def __init__(self,
bot: LionBot, data: Data.ScheduleSessionMember,
lion: LionMember):
self.bot = bot
self.data = data
self.lion = lion
self.slotid = data.slotid
self.slot_start = slotid_to_utc(self.slotid)
self.slot_end = slotid_to_utc(self.slotid + 3600)
self.userid = data.userid
self.guildid = data.guildid
self.clock_start = None
self.clocked = 0
@property
def total_clock(self):
clocked = self.clocked
if self.clock_start is not None:
end = min(utc_now(), self.slot_end)
clocked += (end - self.clock_start).total_seconds()
return clocked
def clock_on(self, at: dt.datetime):
"""
Mark this member as attending the scheduled session.
"""
if self.clock_start:
self.clock_off(at)
self.clock_start = max(self.slot_start, at)
def clock_off(self, at: dt.datetime):
"""
Mark this member as no longer attending.
"""
if not self.clock_start:
raise ValueError("Member clocking off while already off.")
end = min(at, self.slot_end)
self.clocked += (end - self.clock_start).total_seconds()
self.clock_start = None

View File

@@ -0,0 +1,529 @@
from typing import Optional, TYPE_CHECKING
from collections import defaultdict
import datetime as dt
import asyncio
import discord
from meta import LionBot
from meta.sharding import THIS_SHARD
from meta.logger import log_context, log_wrap
from utils.lib import utc_now
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 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 ..settings import ScheduleSettings
from .session import ScheduledSession
from .session_member import SessionMember
if TYPE_CHECKING:
from ..cog import ScheduleCog
_p = babel._p
class TimeSlot:
"""
Represents a single schedule session timeslot.
Maintains a cache of ScheduleSessions for event handling.
Responsible for the state of all scheduled sessions in this timeslot.
Provides methods for executing each stage of the time slot,
performing operations concurrently where possible.
"""
# TODO: Logging context
# TODO: Add per-shard jitter to improve ratelimit handling
def __init__(self, cog: 'ScheduleCog', slot_data: Data.ScheduleSlot):
self.cog = cog
self.bot: LionBot = cog.bot
self.data: Data = cog.data
self.slot_data = slot_data
self.slotid = slot_data.slotid
log_context.set(f"slotid: {self.slotid}")
self.prep_at = slotid_to_utc(self.slotid - 15*60)
self.start_at = slotid_to_utc(self.slotid)
self.end_at = slotid_to_utc(self.slotid + 3600)
self.preparing = asyncio.Event()
self.opening = asyncio.Event()
self.opened = asyncio.Event()
self.closing = asyncio.Event()
self.sessions: dict[int, ScheduledSession] = {} # guildid -> loaded ScheduledSession
self.run_task = None
self.loaded = False
@log_wrap(action="Fetch sessions")
async def fetch(self):
"""
Load all slot sessions from data. Must be executed before reading event based updates.
Does not take session lock because nothing external should read or modify before load.
"""
self.loaded = False
self.sessions.clear()
session_data = await self.data.ScheduleSession.fetch_where(
THIS_SHARD,
slotid=self.slotid,
closed_at=None,
)
sessions = await self.load_sessions(session_data)
self.sessions.update(sessions)
self.loaded = True
logger.info(
f"Timeslot <slotid: {self.slotid}> finished preloading {len(self.sessions)} guilds. Ready to open."
)
@log_wrap(action="Load sessions")
async def load_sessions(self, session_data) -> dict[int, ScheduledSession]:
"""
Load slot state for the provided GuildSchedule rows.
"""
if not session_data:
return {}
guildids = [row.guildid for row in session_data]
# Bulk fetch guild config data
config_data = await self.data.ScheduleGuild.fetch_multiple(*guildids)
# Fetch channel data. This *should* hit cache if initialisation did its job
channel_settings = {guildid: await ScheduleSettings.SessionChannels.get(guildid) for guildid in guildids}
# Data fetch all member schedules with this slotid
members = await self.data.ScheduleSessionMember.fetch_where(
slotid=self.slotid,
guildid=guildids
)
# Bulk fetch lions
lions = await self.bot.core.lions.fetch_members(
*((m.guildid, m.userid) for m in members)
) if members else {}
# Partition member data
session_member_data = defaultdict(list)
for mem in members:
session_member_data[mem.guildid].append(mem)
# Create the session guilds and session members.
sessions = {}
for row in session_data:
session = ScheduledSession(self.bot, row, config_data[row.guildid], channel_settings[row.guildid])
smembers = {}
for memdata in session_member_data[row.guildid]:
smember = SessionMember(
self.bot, memdata, lions[memdata.guildid, memdata.userid]
)
smembers[memdata.userid] = smember
session.members = smembers
sessions[row.guildid] = session
logger.debug(
f"Timeslot <slotid: {self.slotid}> "
f"loaded guild data for {len(sessions)} guilds: {', '.join(map(str, guildids))}"
)
return sessions
@log_wrap(action="Reset Clocks")
async def _reset_clocks(self, sessions: list[ScheduledSession]):
"""
Accurately set clocks (i.e. attendance time) for all tracked members in this time slot.
"""
now = utc_now()
tracker = self.bot.get_cog('VoiceTrackerCog')
tracking_lock = tracker.tracking_lock
session_locks = [session.lock for session in sessions]
# Take the tracking lock so that sessions are not started/finished while we reset the clock
try:
await tracking_lock.acquire()
[await lock.acquire() for lock in session_locks]
if now > self.start_at + dt.timedelta(minutes=5):
# Set initial clocks based on session data
# First request sessions intersection with the timeslot
memberids = [
(sm.data.guildid, sm.data.userid)
for sg in sessions for sm in sg.members.values()
]
session_map = {session.guildid: session for session in sessions}
model = tracker.data.VoiceSessions
if memberids:
voice_sessions = await model.table.select_where(
MEMBERS(*memberids),
model.start_time < self.end_at,
model.start_time + as_duration(model.duration) > self.start_at
).select(
'guildid', 'userid', 'start_time', 'channelid',
end_time=model.start_time + as_duration(model.duration)
).with_no_adapter()
else:
voice_sessions = []
# Intersect and aggregate sessions, accounting for session channels
clocks = defaultdict(int)
for vsession in voice_sessions:
if session_map[vsession['guildid']].validate_channel(vsession['channelid']):
start = max(vsession['start_time'], self.start_at)
end = min(vsession['end_time'], self.end_at)
clocks[(vsession['guildid'], vsession['userid'])] += (end - start).total_seconds()
# Now write clocks
for sg in sessions:
for sm in sg.members.values():
sg.clock = clocks[(sm.guildid, sm.userid)]
# Mark current attendance using current voice session
for session in sessions:
for smember in session.members.values():
voice_session = tracker.get_session(smember.data.guildid, smember.data.userid)
smember.clock_start = None
if voice_session is not None and voice_session.activity is SessionState.ONGOING:
if session.validate_channel(voice_session.data.channelid):
smember.clock_start = max(voice_session.data.start_time, self.start_at)
session.listening = True
finally:
tracking_lock.release()
[lock.release() for lock in session_locks]
@log_wrap(action="Prepare Sessions")
async def prepare(self, sessions: list[ScheduledSession]):
"""
Bulk prepare ScheduledSessions for the upcoming timeslot.
Preparing means sending the initial message and adding permissions for the next members.
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 <slotid: {self.slotid}> with {len(sessions)} sessions.")
try:
coros = [session.prepare(save=False) for session in sessions if session.can_run]
await batchrun_per_second(coros, 5)
# Save messageids
tmptable = TemporaryTable(
'_gid', '_sid', '_mid',
types=('BIGINT', 'INTEGER', 'BIGINT')
)
tmptable.values = [
(sg.data.guildid, sg.data.slotid, sg.messageid)
for sg in sessions
if sg.messageid is not None
]
await Data.ScheduleSession.table.update_where(
guildid=tmptable['_gid'], slotid=tmptable['_sid']
).set(
messageid=tmptable['_mid']
).from_expr(tmptable)
except Exception:
logger.exception(
f"Unhandled exception while preparing timeslot <slotid: {self.slotid}>."
)
else:
logger.info(
f"Prepared {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>"
)
@log_wrap(action="Open Sessions")
async def open(self, sessions: list[ScheduledSession]):
"""
Bulk open guild sessions.
If session opens "late", uses voice session statistics to calculate clock times.
Otherwise, uses member's current sessions.
Due to the bulk channel update, this method may take up to 5 or 10 minutes.
"""
try:
# List of sessions which have not been previously opened
# Used so that we only set channel permissions and notify and write opened once
fresh = [session for session in sessions if session.data.opened_at is None]
# Calculate the attended time so far, referencing voice session data if required
await self._reset_clocks(sessions)
# Bulk update lobby messages
message_tasks = [
asyncio.create_task(session.update_status(save=False))
for session in sessions
if session.lobby_channel is not None
]
notify_tasks = [
asyncio.create_task(session.notify())
for session in fresh
if session.lobby_channel is not None and session.data.opened_at is None
]
# Start lobby update loops
for session in sessions:
session.start_updating()
# Bulk run guild open to open session rooms
voice_coros = [
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)
await asyncio.gather(*message_tasks)
await asyncio.gather(*notify_tasks)
# Write opened
if fresh:
now = utc_now()
tmptable = TemporaryTable(
'_gid', '_sid', '_mid', '_open',
types=('BIGINT', 'INTEGER', 'BIGINT', 'TIMESTAMPTZ')
)
tmptable.values = [
(sg.data.guildid, sg.data.slotid, sg.messageid, now)
for sg in fresh
]
await Data.ScheduleSession.table.update_where(
guildid=tmptable['_gid'], slotid=tmptable['_sid']
).set(
messageid=tmptable['_mid'],
opened_at=tmptable['_open']
).from_expr(tmptable)
except Exception:
logger.exception(
f"Unhandled exception while opening sessions for timeslot <slotid: {self.slotid}>."
)
else:
logger.info(
f"Opened {len(sessions)} sessions for scheduled session timeslot <slotid: {self.slotid}>"
)
@log_wrap(action="Close Sessions")
async def close(self, sessions: list[ScheduledSession], consequences=False):
"""
Close the session.
Responsible for saving the member attendance, performing economy updates,
closing the guild sessions, and if `consequences` is set,
cancels future member sessions and blacklists members as required.
Also performs the last lobby message update for this timeslot.
Does not modify session room channels (responsibility of the next open).
"""
try:
conn = await self.bot.db.get_connection()
async with conn.transaction():
# Calculate rewards
rewards = []
attendance = []
did_not_show = []
for session in sessions:
bonus = session.bonus_reward * session.all_attended
reward = session.attended_reward + bonus
required = session.min_attendence
for member in session.members.values():
guildid = member.guildid
userid = member.userid
attended = (member.total_clock >= required)
if attended:
rewards.append(
(TransactionType.SCHEDULE_REWARD,
guildid, self.bot.user.id,
0, userid,
reward, 0,
None)
)
else:
did_not_show.append((guildid, userid))
attendance.append(
(self.slotid, guildid, userid, attended, member.total_clock)
)
# Perform economy transactions
economy: Economy = self.bot.get_cog('Economy')
transactions = await economy.data.Transaction.execute_transactions(*rewards)
reward_ids = {
(t.guildid, t.to_account): t.transactionid
for t in transactions
}
# Update lobby messages
message_tasks = [
asyncio.create_task(session.update_status(save=False))
for session in sessions
if session.lobby_channel is not None
]
await asyncio.gather(*message_tasks)
# Save attendance
if attendance:
att_table = TemporaryTable(
'_sid', '_gid', '_uid', '_att', '_clock', '_reward',
types=('INTEGER', 'BIGINT', 'BIGINT', 'BOOLEAN', 'INTEGER', 'INTEGER')
)
att_table.values = [
(sid, gid, uid, att, clock, reward_ids.get((gid, uid), None))
for sid, gid, uid, att, clock in attendance
]
await self.data.ScheduleSessionMember.table.update_where(
slotid=att_table['_sid'],
guildid=att_table['_gid'],
userid=att_table['_uid'],
).set(
attended=att_table['_att'],
clock=att_table['_clock'],
reward_transactionid=att_table['_reward']
).from_expr(att_table)
# Mark guild sessions as closed
if sessions:
await self.data.ScheduleSession.table.update_where(
slotid=self.slotid,
guildid=list(session.guildid for session in sessions)
).set(closed_at=utc_now())
if consequences and did_not_show:
# Trigger blacklist and cancel member bookings as needed
await self.cog.handle_noshow(*did_not_show)
except Exception:
logger.exception(
f"Unhandled exception while closing sessions for timeslot <slotid: {self.slotid}>."
)
else:
logger.info(
f"Closed {len(sessions)} for scheduled session timeslot <slotid: {self.slotid}>"
)
def launch(self) -> asyncio.Task:
self.run_task = asyncio.create_task(self.run())
return self.run_task
@log_wrap(action="TimeSlot Run")
async def run(self):
"""
Execute each stage of the scheduled timeslot.
Skips preparation if the open time has passed.
"""
if not self.loaded:
raise ValueError("Attempting to run a Session before loading.")
try:
now = utc_now()
if now < self.start_at:
await discord.utils.sleep_until(self.prep_at)
self.preparing.set()
await self.prepare(list(self.sessions.values()))
else:
await discord.utils.sleep_until(self.start_at)
self.preparing.set()
self.opening.set()
await self.open(list(self.sessions.values()))
self.opened.set()
await discord.utils.sleep_until(self.end_at)
self.closing.set()
await self.close(list(self.sessions.values()), consequences=True)
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 <slotid: {self.slotid}> "
f"with state '{state}'."
)
except Exception:
logger.exception(
f"Unexpected exception occurred while running active time slot <slotid: {self.slotid}>."
)
@log_wrap(action="Slot Cleanup")
async def cleanup(self, sessions: list[ScheduledSession]):
"""
Cleanup after "missed" ScheduledSessions.
Missed sessions are unclosed sessions which are already past their closed time.
If the sessions were opened, they will be closed (with no consequences).
If the sessions were not opened, they will be cancelled (and the bookings refunded).
"""
now = utc_now()
if now < self.end_at:
raise ValueError("Attempting to cleanup sessions in current timeslot. Use close() or cancel() instead.")
# Split provided sessions into ignore/close/cancel
to_close = []
to_cancel = []
for session in sessions:
if session.slotid != self.slotid:
raise ValueError(f"Timeslot {self.slotid} attempting to cleanup session with slotid {session.slotid}")
if session.data.closed_at is not None:
# Already closed, ignore
pass
elif session.data.opened_at is not None:
# Session was opened, request close
to_close.append(session)
else:
# Session was never opened, request cancel
to_cancel.append(session)
# Handle close
if to_close:
await self._reset_clocks(to_close)
await self.close(to_close, consequences=False)
# Handle cancel
if to_cancel:
await self.cancel(to_cancel)
@log_wrap(action="Cancel TimeSlot")
async def cancel(self, sessions: list[ScheduledSession]):
"""
Cancel the provided sessions.
This involves refunding the booking transactions, deleting the booking rows,
and updating any messages that may have been posted.
"""
conn = await self.bot.db.get_connection()
async with conn.transaction():
# Collect booking rows
bookings = [member.data for session in sessions for member in session.members.values()]
if bookings:
# Refund booking transactions
economy: Economy = self.bot.get_cog('Economy')
maybe_tids = (r.book_transactionid for r in bookings)
tids = [tid for tid in maybe_tids if tid is not None]
await economy.data.Transaction.refund_transactions(*tids)
# Delete booking rows
await self.data.ScheduleSessionMember.table.delete_where(
MEMBERS(*((r.guildid, r.userid) for r in bookings)),
slotid=self.slotid,
)
# Trigger message update for existent messages
lobby_tasks = [
asyncio.create_task(session.update_status(save=False, resend=False))
for session in sessions
]
await asyncio.gather(*lobby_tasks)
# Mark sessions as closed
await self.data.ScheduleSession.table.update_where(
slotid=self.slotid,
guildid=[session.guildid for session in sessions]
).set(
closed_at=utc_now()
)
# TODO: Logging