Files
croccybot/bot/modules/accountability/TimeSlot.py

473 lines
16 KiB
Python

from typing import List, Dict
import datetime
import discord
import asyncio
from settings import GuildSettings
from utils.lib import tick, cross
from core import Lion
from meta import client
from .lib import utc_now
from .data import accountability_members, accountability_rooms
class SlotMember:
"""
Class representing a member booked into an accountability room.
Mostly acts as an interface to the corresponding TableRow.
But also stores the discord.Member associated, and has several computed properties.
The member may be None.
"""
___slots__ = ('slotid', 'userid', 'guild')
def __init__(self, slotid, userid, guild):
self.slotid = slotid
self.userid = userid
self.guild = guild
self._member = None
@property
def key(self):
return (self.slotid, self.userid)
@property
def data(self):
return accountability_members.fetch(self.key)
@property
def member(self):
return self.guild.get_member(self.userid)
@property
def has_attended(self):
return self.data.duration > 0 or self.data.last_joined_at
class TimeSlot:
"""
Class representing an accountability slot.
"""
__slots__ = (
'guild',
'start_time',
'data',
'lobby',
'category',
'channel',
'message',
'members'
)
slots = {}
_member_overwrite = discord.PermissionOverwrite(
view_channel=True,
connect=True
)
_everyone_overwrite = discord.PermissionOverwrite(
view_channel=False,
connect=False,
speak=False
)
happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png"
sad_lion = "https://media.discordapp.net/stickers/898266548421148723.png"
def __init__(self, guild, start_time, data=None):
self.guild: discord.Guild = guild
self.start_time: datetime.datetime = start_time
self.data = data
self.lobby: discord.TextChannel = None # Text channel to post the slot status
self.category: discord.CategoryChannel = None # Category to create the voice rooms in
self.channel: discord.VoiceChannel = None # Text channel associated with this time slot
self.message: discord.Message = None # Status message in lobby channel
self.members: Dict[int, SlotMember] = {} # memberid -> SlotMember
@property
def open_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(
text="About to start!\nJoin the session with {}schedule book".format(client.prefix)
)
if self.members:
embed.description = "Starting <t:{}:R>.".format(timestamp)
embed.add_field(
name="Members",
value=(
', '.join('<@{}>'.format(key) for key in self.members.keys())
)
)
else:
embed.description = "No members scheduled this session!"
return embed
@property
def status_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Finishing <t:{}:R>.".format(timestamp + 3600),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(text="Join the next session using {}schedule book".format(client.prefix))
if self.members:
classifications = {
"Attended": [],
"Studying Now": [],
"Waiting for": []
}
for memid, mem in self.members.items():
mention = '<@{}>'.format(memid)
if not mem.has_attended:
classifications["Waiting for"].append(mention)
elif mem.member in self.channel.members:
classifications["Studying Now"].append(mention)
else:
classifications["Attended"].append(mention)
all_attended = all(mem.has_attended for mem in self.members.values())
bonus_line = (
"{tick} Everyone attended, and will get a `{bonus} LC` bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all_attended else ""
)
if all_attended:
embed.set_thumbnail(url=self.happy_lion)
embed.description += "\n" + bonus_line
for field, value in classifications.items():
if value:
embed.add_field(name=field, value='\n'.join(value))
else:
embed.description = "No members scheduled this session!"
return embed
@property
def summary_embed(self):
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Finished <t:{}:R>.".format(timestamp + 3600),
colour=discord.Colour.orange(),
timestamp=self.start_time
).set_footer(text="Completed!")
if self.members:
classifications = {
"Attended": [],
"Missing": []
}
for memid, mem in sorted(self.members.items(), key=lambda mem: mem[1].data.duration, reverse=True):
mention = '<@{}>'.format(memid)
if mem.has_attended:
classifications["Attended"].append(
"{} ({}%)".format(mention, (mem.data.duration * 100) // 3600)
)
else:
classifications["Missing"].append(mention)
all_attended = all(mem.has_attended for mem in self.members.values())
bonus_line = (
"{tick} Everyone attended, and received a `{bonus} LC` bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all_attended else
"{cross} Some members missed the session, so everyone missed out on the bonus!".format(
cross=cross
)
)
if all_attended:
embed.set_thumbnail(url=self.happy_lion)
else:
embed.set_thumbnail(url=self.sad_lion)
embed.description += "\n" + bonus_line
for field, value in classifications.items():
if value:
embed.add_field(name=field, value='\n'.join(value))
else:
embed.description = "No members scheduled this session!"
return embed
def load(self, memberids: List[int] = None):
"""
Load data and update applicable caches.
"""
if not self.guild:
return self
# Load setting data
self.category = GuildSettings(self.guild.id).accountability_category.value
self.lobby = GuildSettings(self.guild.id).accountability_lobby.value
if self.data:
# Load channel
if self.data.channelid:
self.channel = self.guild.get_channel(self.data.channelid)
# Load message
if self.data.messageid and self.lobby:
self.message = discord.PartialMessage(
channel=self.lobby,
id=self.data.messageid
)
# Load members
if memberids:
self.members = {
memberid: SlotMember(self.data.slotid, memberid, self.guild)
for memberid in memberids
}
return self
async def _reload_members(self, memberids=None):
"""
Reload the timeslot members from the provided list, or data.
Also updates the channel overwrites if required.
To be used before the session has started.
"""
if self.data:
if memberids is None:
member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid)
memberids = [row.userid for row in member_rows]
self.members = members = {
memberid: SlotMember(self.data.slotid, memberid, self.guild)
for memberid in memberids
}
if self.channel:
# Check and potentially update overwrites
current_overwrites = self.channel.overwrites
overwrites = {
mem.member: self._member_overwrite
for mem in members.values()
if mem.member
}
overwrites[self.guild.default_role] = self._everyone_overwrite
if current_overwrites != overwrites:
await self.channel.edit(overwrites=overwrites)
def _refresh(self):
"""
Refresh the stored data row and reload.
"""
rows = accountability_rooms.fetch_rows_where(
guildid=self.guild.id,
start_at=self.start_time
)
self.data = rows[0] if rows else None
memberids = []
if self.data:
member_rows = accountability_members.fetch_rows_where(
slotid=self.data.slotid
)
memberids = [row.userid for row in member_rows]
self.load(memberids=memberids)
async def open(self):
"""
Open the accountability room.
Creates a new voice channel, and sends the status message.
Event logs any issues.
Adds the TimeSlot to cache.
Returns the (channelid, messageid).
"""
# Cleanup any non-existent members
for memid, mem in list(self.members.items()):
if not mem.data or not mem.member:
self.members.pop(memid)
# Calculate overwrites
overwrites = {
mem.member: self._member_overwrite
for mem in self.members.values()
}
overwrites[self.guild.default_role] = self._everyone_overwrite
# Create the channel. Log and bail if something went wrong.
if self.data and not self.channel:
try:
self.channel = await self.guild.create_voice_channel(
"Upcoming Scheduled Session",
overwrites=overwrites,
category=self.category
)
except discord.HTTPException:
GuildSettings(self.guild.id).event_log.log(
"Failed to create the scheduled session voice channel. Skipping this session.",
colour=discord.Colour.red()
)
return None
elif not self.data:
self.channel = None
# Send the inital status message. Log and bail if something goes wrong.
if not self.message:
try:
self.message = await self.lobby.send(
embed=self.open_embed
)
except discord.HTTPException:
GuildSettings(self.guild.id).event_log.log(
"Failed to post the status message in the scheduled session lobby {}.\n"
"Skipping this session.".format(self.lobby.mention),
colour=discord.Colour.red()
)
return None
if self.members:
await self.channel_notify()
return (self.channel.id if self.channel else None, self.message.id)
async def channel_notify(self, content=None):
"""
Ghost pings the session members in the lobby channel.
"""
if self.members:
content = content or "Your scheduled session has started! Please join!"
out = "{}\n\n{}".format(
content,
' '.join('<@{}>'.format(memid) for memid, mem in self.members.items() if not mem.has_attended)
)
out_msg = await self.lobby.send(out)
await out_msg.delete()
async def start(self):
"""
Start the accountability room slot.
Update the status message, and launch the DM reminder.
"""
if self.channel:
try:
await self.channel.edit(name="Scheduled Session Room")
await self.channel.set_permissions(self.guild.default_role, view_channel=True, connect=False)
except discord.HTTPException:
pass
asyncio.create_task(self.dm_reminder(delay=60))
try:
await self.message.edit(embed=self.status_embed)
except discord.NotFound:
try:
self.message = await self.lobby.send(
embed=self.status_embed
)
except discord.HTTPException:
self.message = None
async def dm_reminder(self, delay=60):
"""
Notifies missing members with a direct message after 1 minute.
"""
await asyncio.sleep(delay)
embed = discord.Embed(
title="The scheduled session you booked has started!",
description="Please join {}.".format(self.channel.mention),
colour=discord.Colour.orange()
).set_footer(
text=self.guild.name,
icon_url=self.guild.icon_url
)
members = (mem.member for mem in self.members.values() if not mem.has_attended)
members = (member for member in members if member)
await asyncio.gather(
*(member.send(embed=embed) for member in members),
return_exceptions=True
)
async def close(self):
"""
Delete the channel and update the status message to display a session summary.
Unloads the TimeSlot from cache.
"""
if self.channel:
try:
await self.channel.delete()
except discord.HTTPException:
pass
if self.message:
try:
await self.message.edit(embed=self.summary_embed)
except discord.HTTPException:
pass
# Reward members appropriately
if self.guild:
guild_settings = GuildSettings(self.guild.id)
reward = guild_settings.accountability_reward.value
if all(mem.has_attended for mem in self.members.values()):
reward += guild_settings.accountability_bonus.value
for memid in self.members:
Lion.fetch(self.guild.id, memid).addCoins(reward)
async def cancel(self):
"""
Cancel the slot, generally due to missing data.
Updates the message and channel if possible, removes slot from cache, and also updates data.
# TODO: Refund members
"""
if self.data:
self.data.closed_at = utc_now()
if self.channel:
try:
await self.channel.delete()
self.channel = None
except discord.HTTPException:
pass
if self.message:
try:
timestamp = int(self.start_time.timestamp())
embed = discord.Embed(
title="Session <t:{}:t> - <t:{}:t>".format(
timestamp, timestamp + 3600
),
description="Session canceled!",
colour=discord.Colour.red()
)
await self.message.edit(embed=embed)
except discord.HTTPException:
pass
async def update_status(self):
"""
Intelligently update the status message.
"""
if self.message:
if utc_now() < self.start_time:
await self.message.edit(embed=self.open_embed)
elif utc_now() < self.start_time + datetime.timedelta(hours=1):
await self.message.edit(embed=self.status_embed)
else:
await self.message.edit(embed=self.summary_embed)