rewrite: Initial rewrite skeleton.
Remove modules that will no longer be required. Move pending modules to pending-rewrite folders.
This commit is contained in:
477
bot/modules/pending-rewrite/accountability/TimeSlot.py
Normal file
477
bot/modules/pending-rewrite/accountability/TimeSlot.py
Normal file
@@ -0,0 +1,477 @@
|
||||
from typing import List, Dict
|
||||
import datetime
|
||||
import discord
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from settings import GuildSettings
|
||||
from utils.lib import tick, cross
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from .lib import utc_now
|
||||
from .data import accountability_members, accountability_rooms
|
||||
|
||||
|
||||
class SlotMember:
|
||||
"""
|
||||
Class representing a member booked into an accountability room.
|
||||
Mostly acts as an interface to the corresponding TableRow.
|
||||
But also stores the discord.Member associated, and has several computed properties.
|
||||
The member may be None.
|
||||
"""
|
||||
___slots__ = ('slotid', 'userid', 'guild')
|
||||
|
||||
def __init__(self, slotid, userid, guild):
|
||||
self.slotid = slotid
|
||||
self.userid = userid
|
||||
self.guild = guild
|
||||
|
||||
self._member = None
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return (self.slotid, self.userid)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return accountability_members.fetch(self.key)
|
||||
|
||||
@property
|
||||
def member(self):
|
||||
return self.guild.get_member(self.userid)
|
||||
|
||||
@property
|
||||
def has_attended(self):
|
||||
return self.data.duration > 0 or self.data.last_joined_at
|
||||
|
||||
|
||||
class TimeSlot:
|
||||
"""
|
||||
Class representing an accountability slot.
|
||||
"""
|
||||
__slots__ = (
|
||||
'guild',
|
||||
'start_time',
|
||||
'data',
|
||||
'lobby',
|
||||
'category',
|
||||
'channel',
|
||||
'message',
|
||||
'members'
|
||||
)
|
||||
|
||||
slots = {}
|
||||
|
||||
_member_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
connect=True
|
||||
)
|
||||
|
||||
_everyone_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=False,
|
||||
connect=False,
|
||||
speak=False
|
||||
)
|
||||
|
||||
happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png"
|
||||
sad_lion = "https://media.discordapp.net/stickers/898266548421148723.png"
|
||||
|
||||
def __init__(self, guild, start_time, data=None):
|
||||
self.guild: discord.Guild = guild
|
||||
self.start_time: datetime.datetime = start_time
|
||||
self.data = data
|
||||
|
||||
self.lobby: discord.TextChannel = None # Text channel to post the slot status
|
||||
self.category: discord.CategoryChannel = None # Category to create the voice rooms in
|
||||
self.channel: discord.VoiceChannel = None # Text channel associated with this time slot
|
||||
self.message: discord.Message = None # Status message in lobby channel
|
||||
|
||||
self.members: Dict[int, SlotMember] = {} # memberid -> SlotMember
|
||||
|
||||
@property
|
||||
def open_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(
|
||||
text="About to start!\nJoin the session with {}schedule book".format(client.prefix)
|
||||
)
|
||||
|
||||
if self.members:
|
||||
embed.description = "Starting <t:{}:R>.".format(timestamp)
|
||||
embed.add_field(
|
||||
name="Members",
|
||||
value=(
|
||||
', '.join('<@{}>'.format(key) for key in self.members.keys())
|
||||
)
|
||||
)
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def status_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Finishing <t:{}:R>.".format(timestamp + 3600),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(text="Join the next session using {}schedule book".format(client.prefix))
|
||||
|
||||
if self.members:
|
||||
classifications = {
|
||||
"Attended": [],
|
||||
"Studying Now": [],
|
||||
"Waiting for": []
|
||||
}
|
||||
for memid, mem in self.members.items():
|
||||
mention = '<@{}>'.format(memid)
|
||||
if not mem.has_attended:
|
||||
classifications["Waiting for"].append(mention)
|
||||
elif mem.member in self.channel.members:
|
||||
classifications["Studying Now"].append(mention)
|
||||
else:
|
||||
classifications["Attended"].append(mention)
|
||||
|
||||
all_attended = all(mem.has_attended for mem in self.members.values())
|
||||
bonus_line = (
|
||||
"{tick} Everyone attended, and will get a `{bonus} LC` bonus!".format(
|
||||
tick=tick,
|
||||
bonus=GuildSettings(self.guild.id).accountability_bonus.value
|
||||
)
|
||||
if all_attended else ""
|
||||
)
|
||||
if all_attended:
|
||||
embed.set_thumbnail(url=self.happy_lion)
|
||||
|
||||
embed.description += "\n" + bonus_line
|
||||
for field, value in classifications.items():
|
||||
if value:
|
||||
embed.add_field(name=field, value='\n'.join(value))
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def summary_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Finished <t:{}:R>.".format(timestamp + 3600),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(text="Completed!")
|
||||
|
||||
if self.members:
|
||||
classifications = {
|
||||
"Attended": [],
|
||||
"Missing": []
|
||||
}
|
||||
for memid, mem in sorted(self.members.items(), key=lambda mem: mem[1].data.duration, reverse=True):
|
||||
mention = '<@{}>'.format(memid)
|
||||
if mem.has_attended:
|
||||
classifications["Attended"].append(
|
||||
"{} ({}%)".format(mention, (mem.data.duration * 100) // 3600)
|
||||
)
|
||||
else:
|
||||
classifications["Missing"].append(mention)
|
||||
|
||||
all_attended = all(mem.has_attended for mem in self.members.values())
|
||||
|
||||
bonus_line = (
|
||||
"{tick} Everyone attended, and received a `{bonus} LC` bonus!".format(
|
||||
tick=tick,
|
||||
bonus=GuildSettings(self.guild.id).accountability_bonus.value
|
||||
)
|
||||
if all_attended else
|
||||
"{cross} Some members missed the session, so everyone missed out on the bonus!".format(
|
||||
cross=cross
|
||||
)
|
||||
)
|
||||
if all_attended:
|
||||
embed.set_thumbnail(url=self.happy_lion)
|
||||
else:
|
||||
embed.set_thumbnail(url=self.sad_lion)
|
||||
|
||||
embed.description += "\n" + bonus_line
|
||||
for field, value in classifications.items():
|
||||
if value:
|
||||
embed.add_field(name=field, value='\n'.join(value))
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
def load(self, memberids: List[int] = None):
|
||||
"""
|
||||
Load data and update applicable caches.
|
||||
"""
|
||||
if not self.guild:
|
||||
return self
|
||||
|
||||
# Load setting data
|
||||
self.category = GuildSettings(self.guild.id).accountability_category.value
|
||||
self.lobby = GuildSettings(self.guild.id).accountability_lobby.value
|
||||
|
||||
if self.data:
|
||||
# Load channel
|
||||
if self.data.channelid:
|
||||
self.channel = self.guild.get_channel(self.data.channelid)
|
||||
|
||||
# Load message
|
||||
if self.data.messageid and self.lobby:
|
||||
self.message = discord.PartialMessage(
|
||||
channel=self.lobby,
|
||||
id=self.data.messageid
|
||||
)
|
||||
|
||||
# Load members
|
||||
if memberids:
|
||||
self.members = {
|
||||
memberid: SlotMember(self.data.slotid, memberid, self.guild)
|
||||
for memberid in memberids
|
||||
}
|
||||
|
||||
return self
|
||||
|
||||
async def _reload_members(self, memberids=None):
|
||||
"""
|
||||
Reload the timeslot members from the provided list, or data.
|
||||
Also updates the channel overwrites if required.
|
||||
To be used before the session has started.
|
||||
"""
|
||||
if self.data:
|
||||
if memberids is None:
|
||||
member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid)
|
||||
memberids = [row.userid for row in member_rows]
|
||||
|
||||
self.members = members = {
|
||||
memberid: SlotMember(self.data.slotid, memberid, self.guild)
|
||||
for memberid in memberids
|
||||
}
|
||||
|
||||
if self.channel:
|
||||
# Check and potentially update overwrites
|
||||
current_overwrites = self.channel.overwrites
|
||||
overwrites = {
|
||||
mem.member: self._member_overwrite
|
||||
for mem in members.values()
|
||||
if mem.member
|
||||
}
|
||||
overwrites[self.guild.default_role] = self._everyone_overwrite
|
||||
if current_overwrites != overwrites:
|
||||
await self.channel.edit(overwrites=overwrites)
|
||||
|
||||
def _refresh(self):
|
||||
"""
|
||||
Refresh the stored data row and reload.
|
||||
"""
|
||||
rows = accountability_rooms.fetch_rows_where(
|
||||
guildid=self.guild.id,
|
||||
start_at=self.start_time
|
||||
)
|
||||
self.data = rows[0] if rows else None
|
||||
|
||||
memberids = []
|
||||
if self.data:
|
||||
member_rows = accountability_members.fetch_rows_where(
|
||||
slotid=self.data.slotid
|
||||
)
|
||||
memberids = [row.userid for row in member_rows]
|
||||
self.load(memberids=memberids)
|
||||
|
||||
async def open(self):
|
||||
"""
|
||||
Open the accountability room.
|
||||
Creates a new voice channel, and sends the status message.
|
||||
Event logs any issues.
|
||||
Adds the TimeSlot to cache.
|
||||
Returns the (channelid, messageid).
|
||||
"""
|
||||
# Cleanup any non-existent members
|
||||
for memid, mem in list(self.members.items()):
|
||||
if not mem.data or not mem.member:
|
||||
self.members.pop(memid)
|
||||
|
||||
# Calculate overwrites
|
||||
overwrites = {
|
||||
mem.member: self._member_overwrite
|
||||
for mem in self.members.values()
|
||||
}
|
||||
overwrites[self.guild.default_role] = self._everyone_overwrite
|
||||
|
||||
# Create the channel. Log and bail if something went wrong.
|
||||
if self.data and not self.channel:
|
||||
try:
|
||||
self.channel = await self.guild.create_voice_channel(
|
||||
"Upcoming Scheduled Session",
|
||||
overwrites=overwrites,
|
||||
category=self.category
|
||||
)
|
||||
except discord.HTTPException:
|
||||
GuildSettings(self.guild.id).event_log.log(
|
||||
"Failed to create the scheduled session voice channel. Skipping this session.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
return None
|
||||
elif not self.data:
|
||||
self.channel = None
|
||||
|
||||
# Send the inital status message. Log and bail if something goes wrong.
|
||||
if not self.message:
|
||||
try:
|
||||
self.message = await self.lobby.send(
|
||||
embed=self.open_embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
GuildSettings(self.guild.id).event_log.log(
|
||||
"Failed to post the status message in the scheduled session lobby {}.\n"
|
||||
"Skipping this session.".format(self.lobby.mention),
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
return None
|
||||
if self.members:
|
||||
await self.channel_notify()
|
||||
return (self.channel.id if self.channel else None, self.message.id)
|
||||
|
||||
async def channel_notify(self, content=None):
|
||||
"""
|
||||
Ghost pings the session members in the lobby channel.
|
||||
"""
|
||||
if self.members:
|
||||
content = content or "Your scheduled session has started! Please join!"
|
||||
out = "{}\n\n{}".format(
|
||||
content,
|
||||
' '.join('<@{}>'.format(memid) for memid, mem in self.members.items() if not mem.has_attended)
|
||||
)
|
||||
out_msg = await self.lobby.send(out)
|
||||
await out_msg.delete()
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the accountability room slot.
|
||||
Update the status message, and launch the DM reminder.
|
||||
"""
|
||||
dither = 15 * random.random()
|
||||
await asyncio.sleep(dither)
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.edit(name="Scheduled Session Room")
|
||||
await self.channel.set_permissions(self.guild.default_role, view_channel=True, connect=False)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
asyncio.create_task(self.dm_reminder(delay=60))
|
||||
try:
|
||||
await self.message.edit(embed=self.status_embed)
|
||||
except discord.NotFound:
|
||||
try:
|
||||
self.message = await self.lobby.send(
|
||||
embed=self.status_embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
self.message = None
|
||||
|
||||
async def dm_reminder(self, delay=60):
|
||||
"""
|
||||
Notifies missing members with a direct message after 1 minute.
|
||||
"""
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="The scheduled session you booked has started!",
|
||||
description="Please join {}.".format(self.channel.mention),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=self.guild.name,
|
||||
icon_url=self.guild.icon_url
|
||||
)
|
||||
|
||||
members = (mem.member for mem in self.members.values() if not mem.has_attended)
|
||||
members = (member for member in members if member)
|
||||
await asyncio.gather(
|
||||
*(member.send(embed=embed) for member in members),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Delete the channel and update the status message to display a session summary.
|
||||
Unloads the TimeSlot from cache.
|
||||
"""
|
||||
dither = 15 * random.random()
|
||||
await asyncio.sleep(dither)
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if self.message:
|
||||
try:
|
||||
await self.message.edit(embed=self.summary_embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Reward members appropriately
|
||||
if self.guild:
|
||||
guild_settings = GuildSettings(self.guild.id)
|
||||
reward = guild_settings.accountability_reward.value
|
||||
if all(mem.has_attended for mem in self.members.values()):
|
||||
reward += guild_settings.accountability_bonus.value
|
||||
|
||||
for memid in self.members:
|
||||
Lion.fetch(self.guild.id, memid).addCoins(reward, bonus=True)
|
||||
|
||||
async def cancel(self):
|
||||
"""
|
||||
Cancel the slot, generally due to missing data.
|
||||
Updates the message and channel if possible, removes slot from cache, and also updates data.
|
||||
# TODO: Refund members
|
||||
"""
|
||||
if self.data:
|
||||
self.data.closed_at = utc_now()
|
||||
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.delete()
|
||||
self.channel = None
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if self.message:
|
||||
try:
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Session canceled!",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
await self.message.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def update_status(self):
|
||||
"""
|
||||
Intelligently update the status message.
|
||||
"""
|
||||
if self.message:
|
||||
if utc_now() < self.start_time:
|
||||
await self.message.edit(embed=self.open_embed)
|
||||
elif utc_now() < self.start_time + datetime.timedelta(hours=1):
|
||||
await self.message.edit(embed=self.status_embed)
|
||||
else:
|
||||
await self.message.edit(embed=self.summary_embed)
|
||||
6
bot/modules/pending-rewrite/accountability/__init__.py
Normal file
6
bot/modules/pending-rewrite/accountability/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
from . import commands
|
||||
from . import tracker
|
||||
140
bot/modules/pending-rewrite/accountability/admin.py
Normal file
140
bot/modules/pending-rewrite/accountability/admin.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
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 = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_category"
|
||||
_data_column = "accountability_category"
|
||||
|
||||
display_name = "session_category"
|
||||
desc = "Category in which to make the scheduled session rooms."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"\"Schedule session\" category channel.\n"
|
||||
"Scheduled sessions will be held in voice channels created under this category."
|
||||
)
|
||||
_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 session category has been changed to **{}**.".format(self.value.name)
|
||||
else:
|
||||
return "The scheduled session system has been started in **{}**.".format(self.value.name)
|
||||
else:
|
||||
if self.id in AG.cache:
|
||||
aguild = AG.cache.pop(self.id)
|
||||
if aguild.current_slot:
|
||||
asyncio.create_task(aguild.current_slot.cancel())
|
||||
if aguild.upcoming_slot:
|
||||
asyncio.create_task(aguild.upcoming_slot.cancel())
|
||||
return "The scheduled session system has been shut down."
|
||||
else:
|
||||
return "The scheduled session category has been unset."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_lobby(settings.Channel, settings.GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_lobby"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_lobby"
|
||||
desc = "Category in which to post scheduled session notifications updates."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"Scheduled session updates will be posted here, and members will be notified in this channel.\n"
|
||||
"The channel will be automatically created in the configured `session_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 = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_price"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_price"
|
||||
desc = "Cost of booking a scheduled session."
|
||||
|
||||
_default = 500
|
||||
|
||||
long_desc = (
|
||||
"The price of booking each one hour scheduled session slot."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Scheduled session slots now cost `{}` coins.".format(self.value)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_bonus(settings.Integer, GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_bonus"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_bonus"
|
||||
desc = "Bonus given when everyone attends a scheduled session slot."
|
||||
|
||||
_default = 750
|
||||
|
||||
long_desc = (
|
||||
"The extra bonus given to each scheduled session member when everyone who booked attended the session."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Scheduled session members will now get `{}` coins if everyone joins.".format(self.value)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_reward(settings.Integer, GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_reward"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_reward"
|
||||
desc = "The individual reward given when a member attends their booked scheduled session."
|
||||
|
||||
_default = 500
|
||||
|
||||
long_desc = (
|
||||
"Reward given to a member who attends a booked scheduled session."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will now get `{}` coins when they attend their scheduled session.".format(self.value)
|
||||
596
bot/modules/pending-rewrite/accountability/commands.py
Normal file
596
bot/modules/pending-rewrite/accountability/commands.py
Normal file
@@ -0,0 +1,596 @@
|
||||
import re
|
||||
import datetime
|
||||
import discord
|
||||
import asyncio
|
||||
import contextlib
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from meta import client
|
||||
from utils.lib import multiselect_regex, parse_ranges, prop_tabulate
|
||||
from data import NOTNULL
|
||||
from data.conditions import GEQ, LEQ
|
||||
|
||||
from .module import module
|
||||
from .lib import utc_now
|
||||
from .tracker import AccountabilityGuild as AGuild
|
||||
from .tracker import room_lock
|
||||
from .TimeSlot import SlotMember
|
||||
from .data import accountability_members, accountability_member_info, accountability_rooms
|
||||
|
||||
|
||||
hint_icon = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
|
||||
|
||||
|
||||
def time_format(time):
|
||||
diff = (time - utc_now()).total_seconds()
|
||||
if diff < 0:
|
||||
diffstr = "`Right Now!!`"
|
||||
elif diff < 600:
|
||||
diffstr = "`Very soon!!`"
|
||||
elif diff < 3600:
|
||||
diffstr = "`In <1 hour `"
|
||||
else:
|
||||
hours = round(diff / 3600)
|
||||
diffstr = "`In {:>2} hour{}`".format(hours, 's' if hours > 1 else ' ')
|
||||
|
||||
return "{} | <t:{:.0f}:t> - <t:{:.0f}:t>".format(
|
||||
diffstr,
|
||||
time.timestamp(),
|
||||
time.timestamp() + 3600,
|
||||
)
|
||||
|
||||
|
||||
user_locks = {} # Map userid -> ctx
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_exclusive(ctx):
|
||||
"""
|
||||
Cancel any existing exclusive contexts for the author.
|
||||
"""
|
||||
old_ctx = user_locks.pop(ctx.author.id, None)
|
||||
if old_ctx:
|
||||
[task.cancel() for task in old_ctx.tasks]
|
||||
|
||||
user_locks[ctx.author.id] = ctx
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
new_ctx = user_locks.get(ctx.author.id, None)
|
||||
if new_ctx and new_ctx.msg.id == ctx.msg.id:
|
||||
user_locks.pop(ctx.author.id)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="schedule",
|
||||
desc="View your schedule, and get rewarded for attending scheduled sessions!",
|
||||
group="Productivity",
|
||||
aliases=('rooms', 'sessions')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_rooms(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}schedule
|
||||
{prefix}schedule book
|
||||
{prefix}schedule cancel
|
||||
Description:
|
||||
View your schedule with `{prefix}schedule`.
|
||||
Use `{prefix}schedule book` to schedule a session at a selected time..
|
||||
Use `{prefix}schedule cancel` to cancel a scheduled session.
|
||||
"""
|
||||
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 scheduled session system isn't set up!")
|
||||
|
||||
# First grab the sessions the member is booked in
|
||||
joined_rows = accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
start_at=GEQ(utc_now()),
|
||||
_extra="ORDER BY start_at ASC"
|
||||
)
|
||||
|
||||
if command == 'cancel':
|
||||
if not joined_rows:
|
||||
return await ctx.error_reply("You have no scheduled sessions to cancel!")
|
||||
|
||||
# Show unbooking menu
|
||||
lines = [
|
||||
"`[{:>2}]` | {}".format(i, time_format(row['start_at']))
|
||||
for i, row in enumerate(joined_rows)
|
||||
]
|
||||
out_msg = await ctx.reply(
|
||||
content="Please reply with the number(s) of the sessions you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.",
|
||||
embed=discord.Embed(
|
||||
title="Please choose the sessions you want to cancel.",
|
||||
description='\n'.join(lines),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"All times are in your own timezone! Hover over a time to see the date."
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await ctx.cancellable(
|
||||
out_msg,
|
||||
cancel_message="Cancel menu closed, no scheduled sessions were cancelled.",
|
||||
timeout=70
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
with ensure_exclusive(ctx):
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
content=None,
|
||||
embed=discord.Embed(
|
||||
description="Cancel menu timed out, no scheduled sessions were cancelled.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
)
|
||||
await out_msg.clear_reactions()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_cancel = [
|
||||
joined_rows[index]
|
||||
for index in parse_ranges(message.content) if index < len(joined_rows)
|
||||
]
|
||||
if not to_cancel:
|
||||
return await ctx.error_reply("No valid sessions selected for cancellation.")
|
||||
elif any(row['start_at'] < utc_now() for row in to_cancel):
|
||||
return await ctx.error_reply("You can't cancel a running session!")
|
||||
|
||||
slotids = [row['slotid'] for row in to_cancel]
|
||||
async with room_lock:
|
||||
deleted = accountability_members.delete_where(
|
||||
userid=ctx.author.id,
|
||||
slotid=slotids
|
||||
)
|
||||
|
||||
# Handle case where the slot has already opened
|
||||
# TODO: Possible race condition if they open over the hour border? Might never cancel
|
||||
for row in to_cancel:
|
||||
aguild = AGuild.cache.get(row['guildid'], None)
|
||||
if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data:
|
||||
if aguild.upcoming_slot.data.slotid in slotids:
|
||||
aguild.upcoming_slot.members.pop(ctx.author.id, None)
|
||||
if aguild.upcoming_slot.channel:
|
||||
try:
|
||||
await aguild.upcoming_slot.channel.set_permissions(
|
||||
ctx.author,
|
||||
overwrite=None
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await aguild.upcoming_slot.update_status()
|
||||
break
|
||||
|
||||
ctx.alion.addCoins(sum(row[2] for row in deleted))
|
||||
|
||||
remaining = [row for row in joined_rows if row['slotid'] not in slotids]
|
||||
if not remaining:
|
||||
await ctx.embed_reply("Cancelled all your upcoming scheduled sessions!")
|
||||
else:
|
||||
next_booked_time = min(row['start_at'] for row in remaining)
|
||||
if len(to_cancel) > 1:
|
||||
await ctx.embed_reply(
|
||||
"Cancelled `{}` upcoming sessions!\nYour next session is at <t:{:.0f}>.".format(
|
||||
len(to_cancel),
|
||||
next_booked_time.timestamp()
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"Cancelled your session at <t:{:.0f}>!\n"
|
||||
"Your next session is at <t:{:.0f}>.".format(
|
||||
to_cancel[0]['start_at'].timestamp(),
|
||||
next_booked_time.timestamp()
|
||||
)
|
||||
)
|
||||
elif 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
|
||||
already_joined_times = set(row['start_at'] for row in joined_rows)
|
||||
start_time = utc_now().replace(minute=0, second=0, microsecond=0)
|
||||
times = (
|
||||
start_time + datetime.timedelta(hours=n)
|
||||
for n in range(1, 25)
|
||||
)
|
||||
times = [
|
||||
time for time in times
|
||||
if time not in already_joined_times and (time - utc_now()).total_seconds() > 660
|
||||
]
|
||||
lines = [
|
||||
"`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format(
|
||||
num=i,
|
||||
count=attendees.get(time, 0), count_pad=attendee_pad,
|
||||
time=time_format(time),
|
||||
)
|
||||
for i, time in enumerate(times)
|
||||
]
|
||||
# TODO: Nicer embed
|
||||
# TODO: Don't allow multi bookings if the member has a bad attendance rate
|
||||
out_msg = await ctx.reply(
|
||||
content=(
|
||||
"Please reply with the number(s) of the sessions you want to book. E.g. `1, 3, 5` or `1-3, 7-8`."
|
||||
),
|
||||
embed=discord.Embed(
|
||||
title="Please choose the sessions you want to schedule.",
|
||||
description='\n'.join(lines),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"All times are in your own timezone! Hover over a time to see the date."
|
||||
)
|
||||
)
|
||||
)
|
||||
await ctx.cancellable(
|
||||
out_msg,
|
||||
cancel_message="Booking menu cancelled, no sessions were booked.",
|
||||
timeout=60
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
with ensure_exclusive(ctx):
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
content=None,
|
||||
embed=discord.Embed(
|
||||
description="Booking menu timed out, no sessions were booked.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
)
|
||||
await out_msg.clear_reactions()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
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.")
|
||||
elif any(time < utc_now() for time in to_book):
|
||||
return await ctx.error_reply("You can't book a running session!")
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
# Add the member to data, creating the row if required
|
||||
slot_rows = accountability_rooms.fetch_rows_where(
|
||||
guildid=ctx.guild.id,
|
||||
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')
|
||||
)
|
||||
|
||||
# Handle case where the slot has already opened
|
||||
# TODO: Fix this, doesn't always work
|
||||
aguild = AGuild.cache.get(ctx.guild.id, None)
|
||||
if aguild:
|
||||
if aguild.upcoming_slot and aguild.upcoming_slot.start_time in to_book:
|
||||
slot = aguild.upcoming_slot
|
||||
if not slot.data:
|
||||
# Handle slot activation
|
||||
slot._refresh()
|
||||
channelid, messageid = await slot.open()
|
||||
accountability_rooms.update_where(
|
||||
{'channelid': channelid, 'messageid': messageid},
|
||||
slotid=slot.data.slotid
|
||||
)
|
||||
else:
|
||||
slot.members[ctx.author.id] = SlotMember(slot.data.slotid, ctx.author.id, ctx.guild)
|
||||
# Also update the channel permissions
|
||||
try:
|
||||
await slot.channel.set_permissions(ctx.author, view_channel=True, connect=True)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await slot.update_status()
|
||||
ctx.alion.addCoins(-cost)
|
||||
|
||||
# Ack purchase
|
||||
embed = discord.Embed(
|
||||
title="You have scheduled the following session{}!".format('s' if len(to_book) > 1 else ''),
|
||||
description=(
|
||||
"*Please attend all your scheduled sessions!*\n"
|
||||
"*If you can't attend, cancel with* `{}schedule cancel`\n\n{}"
|
||||
).format(
|
||||
ctx.best_prefix,
|
||||
'\n'.join(time_format(time) for time in to_book),
|
||||
),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"Use {prefix}schedule to see your current schedule.\n"
|
||||
).format(prefix=ctx.best_prefix)
|
||||
)
|
||||
try:
|
||||
await ctx.reply(
|
||||
embed=embed,
|
||||
reference=ctx.msg
|
||||
)
|
||||
except discord.NotFound:
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Show accountability room information for this user
|
||||
# Accountability profile
|
||||
# Author
|
||||
# Special case for no past bookings, emphasis hint
|
||||
# Hint on Bookings section for booking/cancelling as applicable
|
||||
# Description has stats
|
||||
# Footer says that all times are in their timezone
|
||||
# TODO: attendance requirement shouldn't be retroactive! Add attended data column
|
||||
# Attended `{}` out of `{}` booked (`{}%` attendance rate!)
|
||||
# Attendance streak: `{}` days attended with no missed sessions!
|
||||
# Add explanation for first time users
|
||||
|
||||
# Get all slots the member has ever booked
|
||||
history = accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
# start_at=LEQ(utc_now() - datetime.timedelta(hours=1)),
|
||||
start_at=LEQ(utc_now()),
|
||||
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
|
||||
_extra="ORDER BY start_at DESC"
|
||||
)
|
||||
|
||||
if not (history or joined_rows):
|
||||
# First-timer information
|
||||
about = (
|
||||
"You haven't scheduled any study sessions yet!\n"
|
||||
"Schedule a session by typing **`{}schedule book`** and selecting "
|
||||
"the hours you intend to study, "
|
||||
"then attend by joining the session voice channel when it starts!\n"
|
||||
"Only if everyone attends will they get the bonus of `{}` LionCoins!\n"
|
||||
"Let's all do our best and keep each other accountable 🔥"
|
||||
).format(
|
||||
ctx.best_prefix,
|
||||
ctx.guild_settings.accountability_bonus.value
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=about,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
embed.set_footer(
|
||||
text="Please keep your DMs open so I can notify you when the session starts!\n",
|
||||
icon_url=hint_icon
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Build description with stats
|
||||
if history:
|
||||
# First get the counts
|
||||
attended_count = sum(row['attended'] for row in history)
|
||||
total_count = len(history)
|
||||
total_duration = sum(row['duration'] for row in history)
|
||||
|
||||
# Add current session to duration if it exists
|
||||
if history[0]['last_joined_at'] and (utc_now() - history[0]['start_at']).total_seconds() < 3600:
|
||||
total_duration += int((utc_now() - history[0]['last_joined_at']).total_seconds())
|
||||
|
||||
# Calculate the streak
|
||||
timezone = ctx.alion.settings.timezone.value
|
||||
|
||||
streak = 0
|
||||
current_streak = None
|
||||
max_streak = 0
|
||||
day_attended = None
|
||||
date = utc_now().astimezone(timezone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
daydiff = datetime.timedelta(days=1)
|
||||
|
||||
i = 0
|
||||
while i < len(history):
|
||||
row = history[i]
|
||||
i += 1
|
||||
if not row['attended']:
|
||||
# Not attended, streak broken
|
||||
pass
|
||||
elif row['start_at'] > date:
|
||||
# They attended this day
|
||||
day_attended = True
|
||||
continue
|
||||
elif day_attended is None:
|
||||
# Didn't attend today, but don't break streak
|
||||
day_attended = False
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
continue
|
||||
elif not day_attended:
|
||||
# Didn't attend the day, streak broken
|
||||
date -= daydiff
|
||||
i -= 1
|
||||
pass
|
||||
else:
|
||||
# Attended the day
|
||||
streak += 1
|
||||
|
||||
# Move window to the previous day and try the row again
|
||||
date -= daydiff
|
||||
day_attended = False
|
||||
i -= 1
|
||||
continue
|
||||
|
||||
max_streak = max(max_streak, streak)
|
||||
if current_streak is None:
|
||||
current_streak = streak
|
||||
streak = 0
|
||||
|
||||
# Handle loop exit state, i.e. the last streak
|
||||
if day_attended:
|
||||
streak += 1
|
||||
max_streak = max(max_streak, streak)
|
||||
if current_streak is None:
|
||||
current_streak = streak
|
||||
|
||||
# Build the stats
|
||||
table = {
|
||||
"Sessions": "**{}** attended out of **{}**, `{:.0f}%` attendance rate.".format(
|
||||
attended_count,
|
||||
total_count,
|
||||
(attended_count * 100) / total_count,
|
||||
),
|
||||
"Time": "**{:02}:{:02}** in scheduled sessions.".format(
|
||||
total_duration // 3600,
|
||||
(total_duration % 3600) // 60
|
||||
),
|
||||
"Streak": "**{}** day{} with no missed sessions! (Longest: **{}** day{}.)".format(
|
||||
current_streak,
|
||||
's' if current_streak != 1 else '',
|
||||
max_streak,
|
||||
's' if max_streak != 1 else '',
|
||||
),
|
||||
}
|
||||
desc = prop_tabulate(*zip(*table.items()))
|
||||
else:
|
||||
desc = (
|
||||
"Good luck with your next session!\n"
|
||||
)
|
||||
|
||||
# Build currently booked list
|
||||
|
||||
if joined_rows:
|
||||
# TODO: (Future) calendar link
|
||||
# Get attendee counts for currently booked sessions
|
||||
rows = accountability_member_info.select_where(
|
||||
slotid=[row["slotid"] for row in joined_rows],
|
||||
userid=NOTNULL,
|
||||
select_columns=(
|
||||
'slotid',
|
||||
'guildid',
|
||||
'start_at',
|
||||
'COUNT(*) as num'
|
||||
),
|
||||
_extra="GROUP BY start_at, slotid, guildid ORDER BY start_at ASC"
|
||||
)
|
||||
attendees = {
|
||||
row['start_at']: (row['num'], row['guildid']) for row in rows
|
||||
}
|
||||
attendee_pad = max((len(str(num)) for num, _ in attendees.values()), default=1)
|
||||
|
||||
# TODO: Allow cancel to accept multiselect keys as args
|
||||
show_guild = any(guildid != ctx.guild.id for _, guildid in attendees.values())
|
||||
guild_map = {}
|
||||
if show_guild:
|
||||
for _, guildid in attendees.values():
|
||||
if guildid not in guild_map:
|
||||
guild = ctx.client.get_guild(guildid)
|
||||
if not guild:
|
||||
try:
|
||||
guild = await ctx.client.fetch_guild(guildid)
|
||||
except discord.HTTPException:
|
||||
guild = None
|
||||
guild_map[guildid] = guild
|
||||
|
||||
booked_list = '\n'.join(
|
||||
"`{:>{}}` attendees | {} {}".format(
|
||||
num,
|
||||
attendee_pad,
|
||||
time_format(start),
|
||||
"" if not show_guild else (
|
||||
"on this server" if guildid == ctx.guild.id else "in **{}**".format(
|
||||
guild_map[guildid] or "Unknown"
|
||||
)
|
||||
)
|
||||
) for start, (num, guildid) in attendees.items()
|
||||
)
|
||||
booked_field = (
|
||||
"{}\n\n"
|
||||
"*If you can't make your session, please cancel using `{}schedule cancel`!*"
|
||||
).format(booked_list, ctx.best_prefix)
|
||||
|
||||
# Temporary footer for acclimatisation
|
||||
# footer = "All times are displayed in your own timezone!"
|
||||
footer = "Book another session using {}schedule book".format(ctx.best_prefix)
|
||||
else:
|
||||
booked_field = (
|
||||
"Your schedule is empty!\n"
|
||||
"Book another session using `{}schedule book`."
|
||||
).format(ctx.best_prefix)
|
||||
footer = "Please keep your DMs open for notifications!"
|
||||
|
||||
# Finally, build embed
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=desc,
|
||||
).set_author(
|
||||
name="Schedule statistics for {}".format(ctx.author.name),
|
||||
icon_url=ctx.author.avatar_url
|
||||
).set_footer(
|
||||
text=footer,
|
||||
icon_url=hint_icon
|
||||
).add_field(
|
||||
name="Upcoming sessions",
|
||||
value=booked_field
|
||||
)
|
||||
|
||||
# And send it!
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
|
||||
# TODO: roomadmin
|
||||
34
bot/modules/pending-rewrite/accountability/data.py
Normal file
34
bot/modules/pending-rewrite/accountability/data.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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')
|
||||
|
||||
# @accountability_member_info.save_query
|
||||
# def user_streaks(userid, min_duration):
|
||||
# with accountability_member_info.conn as conn:
|
||||
# cursor = conn.cursor()
|
||||
# with cursor:
|
||||
# cursor.execute(
|
||||
# """
|
||||
|
||||
# """
|
||||
# )
|
||||
8
bot/modules/pending-rewrite/accountability/lib.py
Normal file
8
bot/modules/pending-rewrite/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/pending-rewrite/accountability/module.py
Normal file
4
bot/modules/pending-rewrite/accountability/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Accountability")
|
||||
515
bot/modules/pending-rewrite/accountability/tracker.py
Normal file
515
bot/modules/pending-rewrite/accountability/tracker.py
Normal file
@@ -0,0 +1,515 @@
|
||||
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 utils.interactive import discord_shield
|
||||
from data import NULL, NOTNULL, tables
|
||||
from data.conditions import LEQ, THIS_SHARD
|
||||
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()
|
||||
room_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def locker(lock):
|
||||
"""
|
||||
Function decorator to wrap the function in a provided Lock
|
||||
"""
|
||||
def decorator(func):
|
||||
async def wrapped(*args, **kwargs):
|
||||
async with lock:
|
||||
return await func(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
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,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
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)
|
||||
|
||||
# Open a new slot in each accountability guild
|
||||
to_update = [] # Cache of slot update data to be applied at the end
|
||||
for aguild in list(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 scheduled session category couldn't be found!\n"
|
||||
"Shutting down the scheduled session system in this server.\n"
|
||||
"To re-activate, please reconfigure `config session_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="session-lobby",
|
||||
category=slot.category,
|
||||
reason="Automatic creation of scheduled session 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 scheduled session 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 a scheduled session 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 + int((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 and 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)'
|
||||
)
|
||||
|
||||
# Close all completed rooms, update data
|
||||
await asyncio.gather(*(slot.close() for slot in last_slots), return_exceptions=True)
|
||||
update_slots = [slot.data.slotid for slot in last_slots if slot.data]
|
||||
if update_slots:
|
||||
accountability_rooms.update_where(
|
||||
{'closed_at': utc_now()},
|
||||
slotid=update_slots
|
||||
)
|
||||
|
||||
# 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?
|
||||
|
||||
# ---------- Start next session ----------
|
||||
current_slots = [
|
||||
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
|
||||
if aguild.current_slot is not None
|
||||
]
|
||||
slotmap = {slot.data.slotid: slot for slot in current_slots if slot.data}
|
||||
|
||||
# Reload the slot members in case they cancelled from another shard
|
||||
member_data = accountability_members.fetch_rows_where(
|
||||
slotid=list(slotmap.keys())
|
||||
) if slotmap else []
|
||||
slot_memberids = {slotid: [] for slotid in slotmap}
|
||||
for row in member_data:
|
||||
slot_memberids[row.slotid].append(row.userid)
|
||||
reload_tasks = (
|
||||
slot._reload_members(memberids=slot_memberids[slotid])
|
||||
for slotid, slot in slotmap.items()
|
||||
)
|
||||
await asyncio.gather(
|
||||
*reload_tasks,
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Move members of the next session over to the session channel
|
||||
movement_tasks = (
|
||||
mem.member.edit(
|
||||
voice_channel=slot.channel,
|
||||
reason="Moving to scheduled session."
|
||||
)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.data and mem.member and 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
|
||||
)
|
||||
|
||||
# Update session data of all members in new channels
|
||||
member_session_data = [
|
||||
(0, slot.start_time, mem.slotid, mem.userid)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.data and mem.member and 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),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
@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 += int((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:
|
||||
next_time = next_time + datetime.timedelta(minutes=5)
|
||||
# Open next sessions
|
||||
try:
|
||||
async with room_lock:
|
||||
await open_next(next_time)
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while opening new scheduled sessions! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="ACCOUNTABILITY_LOOP",
|
||||
level=logging.ERROR
|
||||
)
|
||||
elif next_time.minute == 0:
|
||||
# Start new sessions
|
||||
try:
|
||||
async with room_lock:
|
||||
await turnover()
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while starting scheduled sessions! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="ACCOUNTABILITY_LOOP",
|
||||
level=logging.ERROR
|
||||
)
|
||||
next_time = next_time + datetime.timedelta(minutes=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),
|
||||
guildid=THIS_SHARD,
|
||||
_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 + int((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
|
||||
)
|
||||
if client.get_guild(row.guildid):
|
||||
slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load(
|
||||
memberids=[mow.userid for mow in slot_members[row.slotid]]
|
||||
)
|
||||
try:
|
||||
await slot.close()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
row.closed_at = now
|
||||
|
||||
# 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 + int((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,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
# Further filter out any guilds that we aren't in
|
||||
[AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)]
|
||||
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.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def restore_accountability(client, member):
|
||||
"""
|
||||
Restore accountability channel permissions when a member rejoins the server, if applicable.
|
||||
"""
|
||||
aguild = AccountabilityGuild.cache.get(member.guild.id, None)
|
||||
if aguild:
|
||||
if aguild.current_slot and member.id in aguild.current_slot.members:
|
||||
# Restore member permission for current slot
|
||||
slot = aguild.current_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
if aguild.upcoming_slot and member.id in aguild.upcoming_slot.members:
|
||||
slot = aguild.upcoming_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
Reference in New Issue
Block a user