(Accountability): New module and base system.

This commit is contained in:
2021-09-19 09:56:25 +03:00
parent 4229fe8b18
commit 2ee717bc0c
11 changed files with 1214 additions and 1 deletions

View File

@@ -9,3 +9,4 @@ from .todo import *
from .reminders import *
from .renting import *
# from .moderation import *
from .accountability import *

View File

@@ -0,0 +1,401 @@
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 .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.data.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 = {}
channel_slots = {}
_member_overwrite = discord.PermissionOverwrite(
view_channel=True,
connect=True
)
_everyone_overwrite = discord.PermissionOverwrite(
view_channel=False
)
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):
# TODO Consider adding hint to footer
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!")
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 booked for 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="Running")
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)
bonus_line = (
"{tick} All members attended, and will get a `{bonus} LC` completion bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all(mem.has_attended for mem in self.members.values()) else ""
)
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 booked for 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 self.members.items():
mention = '<@{}>'.format(memid)
if mem.has_attended:
classifications["Attended"].append(
"{} ({}%)".format(mention, (mem.data.duration * 100) // 3600)
)
else:
classifications["Missing"].append(mention)
bonus_line = (
"{tick} All members attended, and received a `{bonus} LC` completion bonus!".format(
tick=tick,
bonus=GuildSettings(self.guild.id).accountability_bonus.value
)
if all(mem.has_attended for mem in self.members.values()) else
"{cross} Some members missed the session, so everyone missed out on the bonus!".format(
cross=cross
)
)
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 booked this session!"
return embed
def load(self, memberids: List[int] = None):
"""
Load data and update applicable caches.
"""
# 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:
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
def _refresh(self):
"""
Refresh the stored data row and reload.
"""
self.data = next(accountability_rooms.fetch_rows_where(
guildid=self.guild.id,
open_at=self.start_time
), None)
self.load()
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).
"""
# 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 Accountability Study Room",
overwrites=overwrites,
category=self.category
)
except discord.HTTPException:
GuildSettings(self.guild.id).event_log.log(
"Failed to create the accountability 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 as e:
print(e)
GuildSettings(self.guild.id).event_log.log(
"Failed to post the status message in the accountability 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 accountability session has opened! 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:
await self.channel.edit(name="Accountability Study Room")
await self.channel.set_permissions(self.guild.default_role, view_channel=True)
asyncio.create_task(self.dm_reminder(delay=60))
await self.message.edit(embed=self.status_embed)
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="Your accountability session 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
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()
except discord.HTTPException:
pass
if self.message:
try:
timestamp = 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)

View File

@@ -0,0 +1,6 @@
from .module import module
from . import data
from . import admin
from . import commands
from . import tracker

View File

@@ -0,0 +1,139 @@
import discord
import settings
from settings import GuildSettings, GuildSetting
from .tracker import AccountabilityGuild as AG
@GuildSettings.attach_setting
class accountability_category(settings.Channel, settings.GuildSetting):
category = "Accountability Rooms"
attr_name = "accountability_category"
_data_column = "accountability_category"
display_name = "accountability_category"
desc = "Category in which to make the accountability rooms."
_default = None
long_desc = (
"\"Accountability\" category channel.\n"
"The accountability voice channels will be created here."
)
_accepts = "A category channel."
_chan_type = discord.ChannelType.category
@property
def success_response(self):
if self.value:
# TODO Move this somewhere better
if self.id not in AG.cache:
AG(self.id)
return "The accountability category has been changed to **{}**.".format(self.value.name)
else:
return "The accountability system has been started in **{}**.".format(self.value.name)
else:
if self.id in AG.cache:
aguild = AG.cache[self.id]
if aguild.current_slot:
aguild.current_lost.cancel()
if aguild.next_slot:
aguild.next_slot.cancel()
return "The accountability system has been stopped."
else:
return "The accountability category has been unset."
@GuildSettings.attach_setting
class accountability_lobby(settings.Channel, settings.GuildSetting):
category = "Accountability Rooms"
attr_name = "accountability_lobby"
_data_column = attr_name
display_name = attr_name
desc = "Category in which to post accountability session status updates."
_default = None
long_desc = (
"Accountability session updates will be posted here, and members will be notified in this channel.\n"
"The channel will be automatically created in the accountability category if it does not exist.\n"
"Members do not need to be able to write in the channel."
)
_accepts = "Any text channel."
_chan_type = discord.ChannelType.text
async def auto_create(self):
# TODO: FUTURE
...
@GuildSettings.attach_setting
class accountability_price(settings.Integer, GuildSetting):
category = "Accountability Rooms"
attr_name = "accountability_price"
_data_column = attr_name
display_name = attr_name
desc = "Cost of booking an accountability time slot."
_default = 100
long_desc = (
"The price of booking each one hour accountability room slot."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Accountability slots now cost `{}` coins.".format(self.value)
@GuildSettings.attach_setting
class accountability_bonus(settings.Integer, GuildSetting):
category = "Accountability Rooms"
attr_name = "accountability_bonus"
_data_column = attr_name
display_name = attr_name
desc = "Bonus given when all accountability members attend a time slot."
_default = 1000
long_desc = (
"The extra bonus given when all the members who have booked an accountability time slot attend."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Accountability members will now get `{}` coins if everyone joins.".format(self.value)
@GuildSettings.attach_setting
class accountability_reward(settings.Integer, GuildSetting):
category = "Accountability Rooms"
attr_name = "accountability_reward"
_data_column = attr_name
display_name = attr_name
desc = "Reward given for attending a booked accountability slot."
_default = 200
long_desc = (
"Amount given to a member who books an accountability slot and attends it."
)
_accepts = "An integer number of coins."
@property
def success_response(self):
return "Accountability members will now get `{}` coins at the end of their slot.".format(self.value)

View File

@@ -0,0 +1,139 @@
import re
import datetime
import discord
import asyncio
from cmdClient.checks import in_guild
from utils.lib import multiselect_regex, parse_ranges
from data import NOTNULL
from .module import module
from .lib import utc_now
from .data import accountability_members, accountability_member_info, accountability_open_slots, accountability_rooms
@module.cmd(
name="rooms",
desc="Book an accountability timeslot",
group="Productivity"
)
@in_guild()
async def cmd_rooms(ctx):
"""
Usage``:
{prefix}rooms
{prefix}rooms book
{prefix}rooms cancel
Description:
...
"""
lower = ctx.args.lower()
splits = lower.split()
command = splits[0] if splits else None
if not ctx.guild_settings.accountability_category.value:
return await ctx.error_reply("The accountability system isn't set up!")
if command == 'book':
# Show booking menu
# Get attendee count
rows = accountability_member_info.select_where(
guildid=ctx.guild.id,
userid=NOTNULL,
select_columns=(
'slotid',
'start_at',
'COUNT(*) as num'
),
_extra="GROUP BY start_at, slotid"
)
attendees = {row['start_at']: row['num'] for row in rows}
attendee_pad = max((len(str(num)) for num in attendees.values()), default=1)
# Build lines
start_time = utc_now().replace(minute=0, second=0, microsecond=0)
times = list(start_time + datetime.timedelta(hours=n) for n in range(0, 25))
lines = [
"`[{:>2}]` | `{:>{}}` attending | <t:{}:d><t:{}:t> - <t:{}:t>".format(
i,
attendees.get(time, 0), attendee_pad,
int(time.timestamp()), int(time.timestamp()), int(time.timestamp()) + 3600
)
for i, time in enumerate(times)
]
# TODO: Nicer embed
# TODO: Remove the slots that the member already booked
out_msg = await ctx.reply(
embed=discord.Embed(
title="Please choose the sessions you want to book.",
description='\n'.join(lines),
colour=discord.Colour.orange()
)
)
def check(msg):
valid = msg.channel == ctx.ch and msg.author == ctx.author
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
return valid
try:
message = await ctx.client.wait_for('message', check=check, timeout=60)
except asyncio.TimeoutError:
await out_msg.delete()
await ctx.error_reply("Session timed out. No accountability slots were booked.")
return
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
to_book = [
times[index]
for index in parse_ranges(message.content) if index < len(times)
]
if not to_book:
return await ctx.error_reply("No valid sessions selected.")
cost = len(to_book) * ctx.guild_settings.accountability_price.value
if cost > ctx.alion.coins:
return await ctx.error_reply(
"Sorry, booking `{}` sessions costs `{}` coins, and you only have `{}`!".format(
len(to_book),
cost,
ctx.alion.coins
)
)
# TODO: Make sure we aren't double-booking
slot_rows = accountability_rooms.fetch_rows_where(
start_at=to_book
)
slotids = [row.slotid for row in slot_rows]
to_add = set(to_book).difference((row.start_at for row in slot_rows))
if to_add:
slotids.extend(row['slotid'] for row in accountability_rooms.insert_many(
*((ctx.guild.id, start_at) for start_at in to_add),
insert_keys=('guildid', 'start_at'),
))
accountability_members.insert_many(
*((slotid, ctx.author.id, ctx.guild_settings.accountability_price.value) for slotid in slotids),
insert_keys=('slotid', 'userid', 'paid')
)
ctx.alion.addCoins(-cost)
await ctx.embed_reply("You have booked `{}` accountability sessions.".format(len(to_book)))
elif command == 'cancel':
# Show unbooking menu
await ctx.reply("[Placeholder text for cancel menu]")
...
else:
# Show current booking information
await ctx.reply("[Placeholder text for current booking information]")
...
# TODO: roomadmin

View File

@@ -0,0 +1,23 @@
from data import Table, RowTable
from cachetools import TTLCache
accountability_rooms = RowTable(
'accountability_slots',
('slotid', 'channelid', 'guildid', 'start_at', 'messageid', 'closed_at'),
'slotid',
cache=TTLCache(5000, ttl=60*70),
attach_as='accountability_rooms'
)
accountability_members = RowTable(
'accountability_members',
('slotid', 'userid', 'paid', 'duration', 'last_joined_at'),
('slotid', 'userid'),
cache=TTLCache(5000, ttl=60*70)
)
accountability_member_info = Table('accountability_member_info')
accountability_open_slots = Table('accountability_open_slots')

View File

@@ -0,0 +1,8 @@
import datetime
def utc_now():
"""
Return the current timezone-aware utc timestamp.
"""
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)

View File

@@ -0,0 +1,4 @@
from LionModule import LionModule
module = LionModule("Accountability")

View File

@@ -0,0 +1,447 @@
import asyncio
import datetime
import collections
import traceback
import logging
import discord
from typing import Dict
from discord.utils import sleep_until
from meta import client
from data import NULL, NOTNULL, tables
from data.conditions import LEQ
from settings import GuildSettings
from .TimeSlot import TimeSlot
from .lib import utc_now
from .data import accountability_rooms, accountability_members
from .module import module
voice_ignore_lock = asyncio.Lock()
class AccountabilityGuild:
__slots__ = ('guildid', 'current_slot', 'upcoming_slot')
cache: Dict[int, 'AccountabilityGuild'] = {} # Map guildid -> AccountabilityGuild
def __init__(self, guildid):
self.guildid = guildid
self.current_slot = None
self.upcoming_slot = None
self.cache[guildid] = self
@property
def guild(self):
return client.get_guild(self.guildid)
@property
def guild_settings(self):
return GuildSettings(self.guildid)
def advance(self):
self.current_slot = self.upcoming_slot
self.upcoming_slot = None
async def open_next(start_time):
"""
Open all the upcoming accountability rooms, and fire channel notify.
To be executed ~5 minutes to the hour.
"""
# Pre-fetch the new slot data, also populating the table caches
room_data = accountability_rooms.fetch_rows_where(
start_at=start_time
)
guild_rows = {row.guildid: row for row in room_data}
member_data = accountability_members.fetch_rows_where(
slotid=[row.slotid for row in room_data]
) if room_data else []
slot_memberids = collections.defaultdict(list)
for row in member_data:
slot_memberids[row.slotid].append(row.userid)
print(room_data, member_data)
# Open a new slot in each accountability guild
to_update = [] # Cache of slot update data to be applied at the end
for aguild in AccountabilityGuild.cache.values():
guild = aguild.guild
if guild:
# Initialise next TimeSlot
slot = TimeSlot(
guild,
start_time,
data=guild_rows.get(aguild.guildid, None)
)
slot.load(memberids=slot_memberids[slot.data.slotid] if slot.data else None)
if not slot.category:
# Log and unload guild
aguild.guild_settings.event_log.log(
"The accountability category couldn't be found!\n"
"Shutting down the accountability system in this server.\n"
"To re-activate, please reconfigure `config accountability_category`."
)
AccountabilityGuild.cache.pop(aguild.guildid, None)
await slot.cancel()
continue
elif not slot.lobby:
# TODO: Consider putting in TimeSlot.open().. or even better in accountability_lobby.create()
# Create a new lobby
try:
channel = await guild.create_text_channel(
name="accountability-lobby",
category=slot.category,
reason="Automatic creation of accountability lobby."
)
aguild.guild_settings.accountability_lobby.value = channel
slot.lobby = channel
except discord.HTTPException:
# Event log failure and skip session
aguild.guild_settings.event_log.log(
"Failed to create the accountability lobby text channel.\n"
"Please set the lobby channel manually with `config`."
)
await slot.cancel()
continue
# Event log creation
aguild.guild_settings.event_log.log(
"Automatically created an accountability lobby channel {}.".format(channel.mention)
)
results = await slot.open()
if results is None:
# Couldn't open the channel for some reason.
# Should already have been logged in `open`.
# Skip this session
await slot.cancel()
continue
elif slot.data:
to_update.append((results[0], results[1], slot.data.slotid))
# Time slot should now be open and ready to start
aguild.upcoming_slot = slot
else:
# Unload guild from cache
AccountabilityGuild.cache.pop(aguild.guildid, None)
# Update slot data
if to_update:
accountability_rooms.update_many(
*to_update,
set_keys=('channelid', 'messageid'),
where_keys=('slotid',)
)
async def turnover():
"""
Switchover from the current accountability rooms to the next ones.
To be executed as close as possible to the hour.
"""
now = utc_now()
# Open event lock so we don't read voice channel movement
async with voice_ignore_lock:
# Update session data for completed sessions
last_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None
]
to_update = [
(mem.data.duration + (now - mem.data.last_joined_at).total_seconds(), None, mem.slotid, mem.userid)
for slot in last_slots for mem in slot.members.values()
if mem.data.last_joined_at
]
if to_update:
accountability_members.update_many(
*to_update,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# Rotate guild sessions
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
# TODO: (FUTURE) with high volume, we might want to start the sessions before moving the members.
# We could break up the session starting?
# Move members of the next session over to the session channel
# This includes any members of the session just complete
current_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None
]
movement_tasks = (
mem.member.edit(
voice_channel=slot.channel,
reason="Moving to booked accountability session."
)
for slot in current_slots
for mem in slot.members.values()
if mem.member.voice and mem.member.voice.channel != slot.channel
)
# We return exceptions here to ignore any permission issues that occur with moving members.
# It's also possible (likely) that members will move while we are moving other members
# Returning the exceptions ensures that they are explicitly ignored
await asyncio.gather(
*movement_tasks,
return_exceptions=True
)
# Close all completed rooms, update data
await asyncio.gather(*(slot.close() for slot in last_slots))
update_slots = [slot.slotid for slot in last_slots if slot.data]
if update_slots:
accountability_rooms.update_where(
{'closed_at': utc_now()},
slotid=update_slots
)
# Update session data of all members in new channels
member_session_data = [
(0, slot.start_at, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if mem.member.voice and mem.member.voice.channel == slot.channel
]
if member_session_data:
accountability_members.update_many(
*member_session_data,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# Start all the current rooms
await asyncio.gather(
*(slot.start() for slot in current_slots)
)
@client.add_after_event('voice_state_update')
async def room_watchdog(client, member, before, after):
"""
Update session data when a member joins or leaves an accountability room.
Ignores events that occur while `voice_ignore_lock` is held.
"""
if not voice_ignore_lock.locked() and before.channel != after.channel:
aguild = AccountabilityGuild.cache.get(member.guild.id)
if aguild and aguild.current_slot and aguild.current_slot.channel:
slot = aguild.current_slot
if member.id in slot.members:
if after.channel and after.channel.id != slot.channel.id:
# Summon them back!
asyncio.create_task(member.edit(voice_channel=slot.channel))
slot_member = slot.members[member.id]
data = slot_member.data
if before.channel and before.channel.id == slot.channel.id:
# Left accountability room
with data.batch_update():
data.duration += (utc_now() - data.last_joined_at).total_seconds()
data.last_joined_at = None
await slot.update_status()
elif after.channel and after.channel.id == slot.channel.id:
# Joined accountability room
with data.batch_update():
data.last_joined_at = utc_now()
await slot.update_status()
async def _accountability_loop():
"""
Runloop in charge of executing the room update tasks at the correct times.
"""
# Wait until ready
while not client.is_ready():
await asyncio.sleep(0.1)
# Calculate starting next_time
# Assume the resume logic has taken care of all events/tasks before current_time
now = utc_now()
if now.minute < 55:
next_time = now.replace(minute=55, second=0, microsecond=0)
else:
next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
# Executor loop
while True:
# TODO: (FUTURE) handle cases where we actually execute much late than expected
await sleep_until(next_time)
if next_time.minute == 55:
# Open next sessions
try:
await open_next(next_time)
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while opening new accountability rooms! "
"Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="ACCOUNTABILITY_LOOP",
level=logging.ERROR
)
next_time = next_time + datetime.timedelta(minutes=5)
elif next_time.minute == 0:
# Start new sessions
try:
await turnover()
except Exception:
# Unknown exception. Catch it so the loop doesn't die.
client.log(
"Error while starting accountability rooms! "
"Exception traceback follows.\n{}".format(
traceback.format_exc()
),
context="ACCOUNTABILITY_LOOP",
level=logging.ERROR
)
next_time = next_time + datetime.timedelta(minute=55)
async def _accountability_system_resume():
"""
Logic for starting the accountability system from cold.
Essentially, session and state resume logic.
"""
now = utc_now()
# Fetch the open room data, only takes into account currently running sessions.
# May include sessions that were never opened, or opened but never started
# Does not include sessions that were opened that start on the next hour
open_room_data = accountability_rooms.fetch_rows_where(
closed_at=NULL,
start_at=LEQ(now),
_extra="ORDER BY start_at ASC"
)
if open_room_data:
# Extract member data of these rows
member_data = accountability_members.fetch_rows_where(
slotid=[row.slotid for row in open_room_data]
)
slot_members = collections.defaultdict(list)
for row in member_data:
slot_members[row.slotid].append(row)
# Filter these into expired rooms and current rooms
expired_room_data = []
current_room_data = []
for row in open_room_data:
if row.start_at + datetime.timedelta(hours=1) < now:
expired_room_data.append(row)
else:
current_room_data.append(row)
session_updates = []
# TODO URGENT: Batch room updates here
# Expire the expired rooms
for row in expired_room_data:
if row.channelid is None or row.messageid is None:
# TODO refunds here
# If the rooms were never opened, close them and skip
row.closed_at = now
else:
# If the rooms were opened and maybe started, make optimistic guesses on session data and close.
session_end = row.start_at + datetime.timedelta(hours=1)
session_updates.extend(
(mow.duration + (session_end - mow.last_joined_at).total_seconds(), None, mow.slotid, mow.userid)
for mow in slot_members[row.slotid] if mow.last_joined_at
)
slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load(
memberids=[mow.userid for mow in slot_members[row.slotid]]
)
row.closed_at = now
try:
await slot.close()
except discord.HTTPException:
pass
# Load the in-progress room data
if current_room_data:
async with voice_ignore_lock:
current_hour = now.replace(minute=0, second=0, microsecond=0)
await open_next(current_hour)
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
current_slots = [
aguild.current_slot
for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot
]
session_updates.extend(
(mem.data.duration + (now - mem.data.last_joined_at).total_seconds(),
None, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if mem.data.last_joined_at and mem.member not in slot.channel.members
)
session_updates.extend(
(mem.data.duration,
now, mem.slotid, mem.userid)
for slot in current_slots
for mem in slot.members.values()
if not mem.data.last_joined_at and mem.member in slot.channel.members
)
if session_updates:
accountability_members.update_many(
*session_updates,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
await asyncio.gather(
*(aguild.current_slot.start()
for aguild in AccountabilityGuild.cache.values() if aguild.current_slot)
)
else:
if session_updates:
accountability_members.update_many(
*session_updates,
set_keys=('duration', 'last_joined_at'),
where_keys=('slotid', 'userid'),
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
)
# If we are in the last five minutes of the hour, open new rooms.
# Note that these may already have been opened, or they may not have been.
if now.minute >= 55:
await open_next(
now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
)
@module.launch_task
async def launch_accountability_system(client):
"""
Launcher for the accountability system.
Resumes saved sessions, and starts the accountability loop.
"""
# Load the AccountabilityGuild cache
guilds = tables.guild_config.fetch_rows_where(
accountability_category=NOTNULL
)
[AccountabilityGuild(guild.guildid) for guild in guilds]
await _accountability_system_resume()
asyncio.create_task(_accountability_loop())
async def unload_accountability(client):
"""
Save the current sessions and cancel the runloop in preparation for client shutdown.
"""
...

View File

@@ -519,3 +519,5 @@ multiselect_regex = re.compile(
r"^([0-9, -]+)$",
re.DOTALL | re.IGNORECASE | re.VERBOSE
)
tick = ''
cross = ''

View File

@@ -50,7 +50,12 @@ CREATE TABLE guild_config(
renting_category BIGINT,
renting_cap INTEGER,
renting_role BIGINT,
renting_sync_perms BOOLEAN
renting_sync_perms BOOLEAN,
accountability_category BIGINT,
accountability_lobby BIGINT,
accountability_bonus INTEGER,
accountability_reward INTEGER,
accountability_price INTEGER
);
CREATE TABLE unranked_roles(
@@ -318,4 +323,42 @@ CREATE TABLE rented_members(
);
CREATE INDEX rented_members_channels ON rented_members (channelid);
-- }}}
-- Accountability Rooms {{{
CREATE TABLE accountability_slots(
slotid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL REFERENCES guild_config(guildid),
channelid BIGINT,
start_at TIMESTAMPTZ (0) NOT NULL,
messageid BIGINT,
closed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX slot_channels ON accountability_slots(channelid);
CREATE UNIQUE INDEX slot_guilds ON accountability_slots(guildid, start_at);
CREATE INDEX slot_times ON accountability_slots(start_at);
CREATE TABLE accountability_members(
slotid INTEGER NOT NULL REFERENCES accountability_slots(slotid) ON DELETE CASCADE,
userid BIGINT NOT NULL,
paid INTEGER NOT NULL,
duration INTEGER DEFAULT 0,
last_joined_at TIMESTAMPTZ,
PRIMARY KEY (slotid, userid)
);
CREATE INDEX slot_members ON accountability_members(userid);
CREATE INDEX slot_members_slotid ON accountability_members(slotid);
CREATE VIEW accountability_member_info AS
SELECT
*
FROM accountability_members
JOIN accountability_slots USING (slotid);
CREATE VIEW accountability_open_slots AS
SELECT
*
FROM accountability_slots
WHERE closed_at IS NULL
ORDER BY start_at ASC;
-- }}}
-- vim: set fdm=marker: