From 2ee717bc0c36a453c13bef8c7b35669442d2beb0 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 19 Sep 2021 09:56:25 +0300 Subject: [PATCH] (Accountability): New module and base system. --- bot/modules/__init__.py | 1 + bot/modules/accountability/TimeSlot.py | 401 ++++++++++++++++++++++ bot/modules/accountability/__init__.py | 6 + bot/modules/accountability/admin.py | 139 ++++++++ bot/modules/accountability/commands.py | 139 ++++++++ bot/modules/accountability/data.py | 23 ++ bot/modules/accountability/lib.py | 8 + bot/modules/accountability/module.py | 4 + bot/modules/accountability/tracker.py | 447 +++++++++++++++++++++++++ bot/utils/lib.py | 2 + data/schema.sql | 45 ++- 11 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 bot/modules/accountability/TimeSlot.py create mode 100644 bot/modules/accountability/admin.py create mode 100644 bot/modules/accountability/commands.py create mode 100644 bot/modules/accountability/data.py create mode 100644 bot/modules/accountability/lib.py create mode 100644 bot/modules/accountability/module.py create mode 100644 bot/modules/accountability/tracker.py diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index ad94ecff..fa0d8bd9 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -9,3 +9,4 @@ from .todo import * from .reminders import * from .renting import * # from .moderation import * +from .accountability import * diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py new file mode 100644 index 00000000..34a43dcc --- /dev/null +++ b/bot/modules/accountability/TimeSlot.py @@ -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 - ".format( + timestamp, timestamp + 3600 + ), + colour=discord.Colour.orange(), + timestamp=self.start_time + ).set_footer(text="About to start!") + + if self.members: + embed.description = "Starting .".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 - ".format( + timestamp, timestamp + 3600 + ), + description="Finishing .".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 - ".format( + timestamp, timestamp + 3600 + ), + description="Finished .".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 - ".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) diff --git a/bot/modules/accountability/__init__.py b/bot/modules/accountability/__init__.py index e69de29b..79fd7162 100644 --- a/bot/modules/accountability/__init__.py +++ b/bot/modules/accountability/__init__.py @@ -0,0 +1,6 @@ +from .module import module + +from . import data +from . import admin +from . import commands +from . import tracker diff --git a/bot/modules/accountability/admin.py b/bot/modules/accountability/admin.py new file mode 100644 index 00000000..2584cb90 --- /dev/null +++ b/bot/modules/accountability/admin.py @@ -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) diff --git a/bot/modules/accountability/commands.py b/bot/modules/accountability/commands.py new file mode 100644 index 00000000..a9320f19 --- /dev/null +++ b/bot/modules/accountability/commands.py @@ -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 | - ".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 diff --git a/bot/modules/accountability/data.py b/bot/modules/accountability/data.py new file mode 100644 index 00000000..ef8ebd2b --- /dev/null +++ b/bot/modules/accountability/data.py @@ -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') diff --git a/bot/modules/accountability/lib.py b/bot/modules/accountability/lib.py new file mode 100644 index 00000000..daf11e9a --- /dev/null +++ b/bot/modules/accountability/lib.py @@ -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) diff --git a/bot/modules/accountability/module.py b/bot/modules/accountability/module.py new file mode 100644 index 00000000..7efc388b --- /dev/null +++ b/bot/modules/accountability/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Accountability") diff --git a/bot/modules/accountability/tracker.py b/bot/modules/accountability/tracker.py new file mode 100644 index 00000000..2f56a442 --- /dev/null +++ b/bot/modules/accountability/tracker.py @@ -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. + """ + ... diff --git a/bot/utils/lib.py b/bot/utils/lib.py index 7e0959f8..3c113f4f 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -519,3 +519,5 @@ multiselect_regex = re.compile( r"^([0-9, -]+)$", re.DOTALL | re.IGNORECASE | re.VERBOSE ) +tick = '✅' +cross = '❌' diff --git a/data/schema.sql b/data/schema.sql index 12addb55..a0c07f45 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -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: