rewrite: New Scheduled Session System.
This commit is contained in:
3
src/modules/schedule/core/__init__.py
Normal file
3
src/modules/schedule/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .session_member import SessionMember
|
||||
from .session import ScheduledSession
|
||||
from .timeslot import TimeSlot
|
||||
544
src/modules/schedule/core/session.py
Normal file
544
src/modules/schedule/core/session.py
Normal 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
|
||||
69
src/modules/schedule/core/session_member.py
Normal file
69
src/modules/schedule/core/session_member.py
Normal 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
|
||||
529
src/modules/schedule/core/timeslot.py
Normal file
529
src/modules/schedule/core/timeslot.py
Normal 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
|
||||
Reference in New Issue
Block a user