(Accountability): New module and base system.
This commit is contained in:
@@ -9,3 +9,4 @@ from .todo import *
|
||||
from .reminders import *
|
||||
from .renting import *
|
||||
# from .moderation import *
|
||||
from .accountability import *
|
||||
|
||||
401
bot/modules/accountability/TimeSlot.py
Normal file
401
bot/modules/accountability/TimeSlot.py
Normal 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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
from . import commands
|
||||
from . import tracker
|
||||
|
||||
139
bot/modules/accountability/admin.py
Normal file
139
bot/modules/accountability/admin.py
Normal 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)
|
||||
139
bot/modules/accountability/commands.py
Normal file
139
bot/modules/accountability/commands.py
Normal 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
|
||||
23
bot/modules/accountability/data.py
Normal file
23
bot/modules/accountability/data.py
Normal 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')
|
||||
8
bot/modules/accountability/lib.py
Normal file
8
bot/modules/accountability/lib.py
Normal 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)
|
||||
4
bot/modules/accountability/module.py
Normal file
4
bot/modules/accountability/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Accountability")
|
||||
447
bot/modules/accountability/tracker.py
Normal file
447
bot/modules/accountability/tracker.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
@@ -519,3 +519,5 @@ multiselect_regex = re.compile(
|
||||
r"^([0-9, -]+)$",
|
||||
re.DOTALL | re.IGNORECASE | re.VERBOSE
|
||||
)
|
||||
tick = '✅'
|
||||
cross = '❌'
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user