rewrite: Initial rewrite skeleton.
Remove modules that will no longer be required. Move pending modules to pending-rewrite folders.
This commit is contained in:
5
bot/modules/pending-rewrite/study/__init__.py
Normal file
5
bot/modules/pending-rewrite/study/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .module import module
|
||||
|
||||
from . import badges
|
||||
from . import timers
|
||||
from . import tracking
|
||||
2
bot/modules/pending-rewrite/study/badges/__init__.py
Normal file
2
bot/modules/pending-rewrite/study/badges/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import badge_tracker
|
||||
from . import studybadge_cmd
|
||||
349
bot/modules/pending-rewrite/study/badges/badge_tracker.py
Normal file
349
bot/modules/pending-rewrite/study/badges/badge_tracker.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import datetime
|
||||
import traceback
|
||||
import logging
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
import discord
|
||||
|
||||
from meta import client, sharding
|
||||
from data.conditions import GEQ, THIS_SHARD
|
||||
from core.data import lions
|
||||
from utils.lib import strfdur
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import new_study_badges, study_badges
|
||||
|
||||
|
||||
guild_locks = {} # guildid -> Lock
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def guild_lock(guildid):
|
||||
"""
|
||||
Per-guild lock held while the study badges are being updated.
|
||||
This should not be used to lock the data modifications, as those are synchronous.
|
||||
Primarily for reporting and so that the member information (e.g. roles) stays consistent
|
||||
through reading and manipulation.
|
||||
"""
|
||||
# Create the lock if it hasn't been registered already
|
||||
if guildid in guild_locks:
|
||||
lock = guild_locks[guildid]
|
||||
else:
|
||||
lock = guild_locks[guildid] = asyncio.Lock()
|
||||
|
||||
await lock.acquire()
|
||||
try:
|
||||
yield lock
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
async def update_study_badges(full=False):
|
||||
while not client.is_ready():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
client.log(
|
||||
"Running global study badge update.".format(
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
# TODO: Consider db procedure for doing the update and returning rows
|
||||
|
||||
# Retrieve member rows with out of date study badges
|
||||
if not full and client.appdata.last_study_badge_scan is not None:
|
||||
# TODO: _extra here is a hack to cover for inflexible conditionals
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=THIS_SHARD,
|
||||
_timestamp=GEQ(client.appdata.last_study_badge_scan or 0),
|
||||
_extra="OR session_start IS NOT NULL AND (guildid >> 22) %% {} = {}".format(
|
||||
sharding.shard_count, sharding.shard_number
|
||||
)
|
||||
)
|
||||
else:
|
||||
update_rows = new_study_badges.select_where(guildid=THIS_SHARD)
|
||||
|
||||
if not update_rows:
|
||||
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
|
||||
return
|
||||
|
||||
# Batch and fire guild updates
|
||||
current_guildid = None
|
||||
current_guild = None
|
||||
guild_buffer = []
|
||||
updated_guilds = set()
|
||||
for row in update_rows:
|
||||
if row['guildid'] != current_guildid:
|
||||
if current_guild:
|
||||
# Fire guild updater
|
||||
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
|
||||
updated_guilds.add(current_guild.id)
|
||||
|
||||
guild_buffer = []
|
||||
current_guildid = row['guildid']
|
||||
current_guild = client.get_guild(row['guildid'])
|
||||
|
||||
if current_guild:
|
||||
guild_buffer.append(row)
|
||||
|
||||
if current_guild:
|
||||
# Fire guild updater
|
||||
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
|
||||
updated_guilds.add(current_guild.id)
|
||||
|
||||
# Update the member study badges in data
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], row['guildid'], row['userid'])
|
||||
for row in update_rows if row['guildid'] in updated_guilds),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# Update the app scan time
|
||||
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
|
||||
|
||||
|
||||
async def _update_guild_badges(guild, member_rows, notify=True, log=True):
|
||||
"""
|
||||
Notify, update, and log role changes for a single guild.
|
||||
Expects a valid `guild` and a list of Rows of `new_study_badges`.
|
||||
"""
|
||||
async with guild_lock(guild.id):
|
||||
client.log(
|
||||
"Running guild badge update for guild '{guild.name}' (gid:{guild.id}) "
|
||||
"with `{count}` rows to update.".format(
|
||||
guild=guild,
|
||||
count=len(member_rows)
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG,
|
||||
post=False
|
||||
)
|
||||
|
||||
# Set of study role ids in this guild, usually from cache
|
||||
guild_roles = {
|
||||
roleid: guild.get_role(roleid)
|
||||
for roleid in study_badges.queries.for_guild(guild.id)
|
||||
}
|
||||
|
||||
log_lines = []
|
||||
flags_used = set()
|
||||
tasks = []
|
||||
for row in member_rows:
|
||||
# Fetch member
|
||||
# TODO: Potential verification issue
|
||||
member = guild.get_member(row['userid'])
|
||||
|
||||
if member:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
_update_member_roles(row, member, guild_roles, log_lines, flags_used, notify)
|
||||
)
|
||||
)
|
||||
|
||||
# Post to the event log, in multiple pages if required
|
||||
event_log = GuildSettings(guild.id).event_log.value
|
||||
if tasks:
|
||||
task_blocks = (tasks[i:i+20] for i in range(0, len(tasks), 20))
|
||||
for task_block in task_blocks:
|
||||
# Execute the tasks
|
||||
await asyncio.gather(*task_block)
|
||||
|
||||
# Post to the log if needed
|
||||
if log and event_log:
|
||||
desc = "\n".join(log_lines)
|
||||
embed = discord.Embed(
|
||||
title="Study badge{} earned!".format('s' if len(log_lines) > 1 else ''),
|
||||
description=desc,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
if flags_used:
|
||||
flag_desc = {
|
||||
'!': "`!` Could not add/remove badge role. **Check permissions!**",
|
||||
'*': "`*` Could not message member.",
|
||||
'x': "`x` Couldn't find role to add/remove!"
|
||||
}
|
||||
flag_lines = '\n'.join(desc for flag, desc in flag_desc.items() if flag in flags_used)
|
||||
embed.add_field(
|
||||
name="Legend",
|
||||
value=flag_lines
|
||||
)
|
||||
try:
|
||||
await event_log.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# Nothing we can really do
|
||||
pass
|
||||
|
||||
# Flush the log collection pointers
|
||||
log_lines.clear()
|
||||
flags_used.clear()
|
||||
|
||||
# Wait so we don't get ratelimited
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Debug log completion
|
||||
client.log(
|
||||
"Completed guild badge update for guild '{guild.name}' (gid:{guild.id})".format(
|
||||
guild=guild,
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG,
|
||||
post=False
|
||||
)
|
||||
|
||||
|
||||
async def _update_member_roles(row, member, guild_roles, log_lines, flags_used, notify):
|
||||
guild = member.guild
|
||||
|
||||
# Logging flag chars
|
||||
flags = []
|
||||
|
||||
# Add new study role
|
||||
# First fetch the roleid using the current_study_badgeid
|
||||
new_row = study_badges.fetch(row['current_study_badgeid']) if row['current_study_badgeid'] else None
|
||||
|
||||
# Fetch actual role from the precomputed guild roles
|
||||
to_add = guild_roles.get(new_row.roleid, None) if new_row else None
|
||||
if to_add:
|
||||
# Actually add the role
|
||||
try:
|
||||
await member.add_roles(
|
||||
to_add,
|
||||
atomic=True,
|
||||
reason="Updating study badge."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
flags.append('!')
|
||||
elif new_row:
|
||||
flags.append('x')
|
||||
|
||||
# Remove other roles, start by trying the last badge role
|
||||
old_row = study_badges.fetch(row['last_study_badgeid']) if row['last_study_badgeid'] else None
|
||||
|
||||
member_roleids = set(role.id for role in member.roles)
|
||||
if old_row and old_row.roleid in member_roleids:
|
||||
# The last level role exists, try to remove it
|
||||
try:
|
||||
await member.remove_roles(
|
||||
guild_roles.get(old_row.roleid),
|
||||
atomic=True
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# Couldn't remove the role
|
||||
flags.append('!')
|
||||
else:
|
||||
# The last level role doesn't exist or the member doesn't have it
|
||||
# Remove all leveled roles they have
|
||||
current_roles = (
|
||||
role for roleid, role in guild_roles.items()
|
||||
if roleid in member_roleids and (not to_add or roleid != to_add.id)
|
||||
)
|
||||
if current_roles:
|
||||
try:
|
||||
await member.remove_roles(
|
||||
*current_roles,
|
||||
atomic=True,
|
||||
reason="Updating study badge."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# Couldn't remove one or more of the leveled roles
|
||||
flags.append('!')
|
||||
|
||||
# Send notification to member
|
||||
# TODO: Config customisation
|
||||
if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time):
|
||||
req = new_row.required_time
|
||||
if req < 3600:
|
||||
timestr = "{} minutes".format(int(req // 60))
|
||||
elif req == 3600:
|
||||
timestr = "1 hour"
|
||||
elif req % 3600:
|
||||
timestr = "{:.1f} hours".format(req / 3600)
|
||||
else:
|
||||
timestr = "{} hours".format(int(req // 3600))
|
||||
embed = discord.Embed(
|
||||
title="New Study Badge!",
|
||||
description="Congratulations! You have earned {} for studying **{}**!".format(
|
||||
"**{}**".format(to_add.name) if to_add else "a new study badge!",
|
||||
timestr
|
||||
),
|
||||
timestamp=datetime.datetime.utcnow(),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(text=guild.name, icon_url=guild.icon_url)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
flags.append('*')
|
||||
|
||||
# Add to event log message
|
||||
if new_row:
|
||||
new_role_str = "earned <@&{}> **({})**".format(new_row.roleid, strfdur(new_row.required_time))
|
||||
else:
|
||||
new_role_str = "lost their study badge!"
|
||||
log_lines.append(
|
||||
"<@{}> {} {}".format(
|
||||
row['userid'],
|
||||
new_role_str,
|
||||
"`[{}]`".format(''.join(flags)) if flags else "",
|
||||
)
|
||||
)
|
||||
if flags:
|
||||
flags_used.update(flags)
|
||||
|
||||
|
||||
async def study_badge_tracker():
|
||||
"""
|
||||
Runloop for the study badge updater.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await update_study_badges()
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while updating study badges! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="STUDY_BADGE_TRACKER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
# Long delay since this is primarily needed for external modifications
|
||||
# or badge updates while studying
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def update_member_studybadge(member):
|
||||
"""
|
||||
Checks and (if required) updates the study badge for a single member.
|
||||
"""
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id
|
||||
)
|
||||
if update_rows:
|
||||
# Debug log the update
|
||||
client.log(
|
||||
"Updating study badge for user '{member.name}' (uid:{member.id}) "
|
||||
"in guild '{member.guild.name}' (gid:{member.guild.id}).".format(
|
||||
member=member
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
# Update the data first
|
||||
lions.update_where({'last_study_badgeid': update_rows[0]['current_study_badgeid']},
|
||||
guildid=member.guild.id, userid=member.id)
|
||||
|
||||
# Run the update task
|
||||
await _update_guild_badges(member.guild, update_rows)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_study_badge_tracker(client):
|
||||
asyncio.create_task(study_badge_tracker())
|
||||
24
bot/modules/pending-rewrite/study/badges/data.py
Normal file
24
bot/modules/pending-rewrite/study/badges/data.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from cachetools import cached
|
||||
|
||||
from data import Table, RowTable
|
||||
|
||||
study_badges = RowTable(
|
||||
'study_badges',
|
||||
('badgeid', 'guildid', 'roleid', 'required_time'),
|
||||
'badgeid'
|
||||
)
|
||||
|
||||
current_study_badges = Table('current_study_badges')
|
||||
|
||||
new_study_badges = Table('new_study_badges')
|
||||
|
||||
|
||||
# Cache of study role ids attached to each guild. Not automatically updated.
|
||||
guild_role_cache = {} # guildid -> set(roleids)
|
||||
|
||||
|
||||
@study_badges.save_query
|
||||
@cached(guild_role_cache)
|
||||
def for_guild(guildid):
|
||||
rows = study_badges.fetch_rows_where(guildid=guildid)
|
||||
return set(row.roleid for row in rows)
|
||||
462
bot/modules/pending-rewrite/study/badges/studybadge_cmd.py
Normal file
462
bot/modules/pending-rewrite/study/badges/studybadge_cmd.py
Normal file
@@ -0,0 +1,462 @@
|
||||
import re
|
||||
import asyncio
|
||||
import discord
|
||||
import datetime
|
||||
|
||||
from cmdClient.checks import in_guild
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from data import NULL
|
||||
from utils.lib import parse_dur, strfdur, parse_ranges
|
||||
from wards import is_guild_admin
|
||||
from core.data import lions
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import study_badges, guild_role_cache, new_study_badges
|
||||
from .badge_tracker import _update_guild_badges
|
||||
|
||||
|
||||
_multiselect_regex = re.compile(
|
||||
r"^([0-9, -]+)$",
|
||||
re.DOTALL | re.IGNORECASE | re.VERBOSE
|
||||
)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"studybadges",
|
||||
group="Guild Configuration",
|
||||
desc="View or configure the server study badges.",
|
||||
aliases=('studyroles', 'studylevels'),
|
||||
flags=('add', 'remove', 'clear', 'refresh')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_studybadges(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}studybadges
|
||||
{prefix}studybadges [--add] <role>, <duration>
|
||||
{prefix}studybadges --remove
|
||||
{prefix}studybadges --remove <role>
|
||||
{prefix}studybadges --remove <badge index>
|
||||
{prefix}studybadges --clear
|
||||
{prefix}studybadges --refresh
|
||||
Description:
|
||||
View or modify the study badges in this guild.
|
||||
|
||||
*Modification requires administrator permissions.*
|
||||
Flags::
|
||||
add: Add new studybadges (each line is added as a separate badge).
|
||||
remove: Remove badges. With no arguments, opens a selection menu.
|
||||
clear: Remove all study badges.
|
||||
refresh: Make sure everyone's study badges are up to date.
|
||||
Examples``:
|
||||
{prefix}studybadges Lion Cub, 100h
|
||||
{prefix}studybadges --remove Lion Cub
|
||||
"""
|
||||
if flags['refresh']:
|
||||
await ensure_admin(ctx)
|
||||
|
||||
# Count members who need updating.
|
||||
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
|
||||
update_count = new_study_badges.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
if not update_count:
|
||||
# No-one needs updating
|
||||
await ctx.reply("All study badges are up to date!")
|
||||
return
|
||||
else:
|
||||
out_msg = await ctx.reply("Updating `{}` members (this may take a while)...".format(update_count))
|
||||
|
||||
# Fetch actual update rows
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
# Update data first
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Then apply the role updates and send notifications as usual
|
||||
await _update_guild_badges(ctx.guild, update_rows)
|
||||
|
||||
await out_msg.edit("Refresh complete! All study badges are up to date.")
|
||||
elif flags['clear'] or flags['remove']:
|
||||
# Make sure that the author is an admin before modifying the roles
|
||||
await ensure_admin(ctx)
|
||||
|
||||
# Pre-fetch the list of roles
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
if not guild_roles:
|
||||
return await ctx.error_reply("There are no studybadges to remove!")
|
||||
|
||||
# Input handling, parse or get the list of rows to delete
|
||||
to_delete = []
|
||||
if flags['remove']:
|
||||
if ctx.args:
|
||||
if ctx.args.isdigit() and 0 < int(ctx.args) <= len(guild_roles):
|
||||
# Assume it is a badge index
|
||||
row = guild_roles[int(ctx.args) - 1]
|
||||
else:
|
||||
# Assume the input is a role string
|
||||
# Get the collection of roles to search
|
||||
roleids = (row.roleid for row in guild_roles)
|
||||
roles = (ctx.guild.get_role(roleid) for roleid in roleids)
|
||||
roles = [role for role in roles if role is not None]
|
||||
role = await ctx.find_role(ctx.args, interactive=True, collection=roles, allow_notfound=False)
|
||||
index = roles.index(role)
|
||||
row = guild_roles[index]
|
||||
|
||||
# We now have a row to delete
|
||||
to_delete = [row]
|
||||
else:
|
||||
# Multi-select the badges to remove
|
||||
out_msg = await show_badge_list(
|
||||
ctx,
|
||||
desc="Please select the badge(s) to delete, or type `c` to cancel.",
|
||||
guild_roles=guild_roles
|
||||
)
|
||||
|
||||
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 study badges were deleted.")
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_delete = [
|
||||
guild_roles[index-1]
|
||||
for index in parse_ranges(message.content) if index <= len(guild_roles)
|
||||
]
|
||||
elif flags['clear']:
|
||||
if not await ctx.ask("Are you sure you want to delete **all** study badges in this server?"):
|
||||
return
|
||||
to_delete = guild_roles
|
||||
|
||||
# In some cases we may come out with no valid rows, in this case cancel.
|
||||
if not to_delete:
|
||||
return await ctx.error_reply("No matching badges, nothing to do!")
|
||||
|
||||
# Count the affected users
|
||||
affected_count = lions.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
last_study_badgeid=[row.badgeid for row in to_delete],
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
# Delete the rows
|
||||
study_badges.delete_where(badgeid=[row.badgeid for row in to_delete])
|
||||
|
||||
# Also update the cached guild roles
|
||||
guild_role_cache.pop((ctx.guild.id, ), None)
|
||||
study_badges.queries.for_guild(ctx.guild.id)
|
||||
|
||||
# Immediately refresh the member data, only for members with NULL badgeid
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
last_study_badgeid=NULL
|
||||
)
|
||||
|
||||
if update_rows:
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Launch the update task for these members, so that they get the correct new roles
|
||||
asyncio.create_task(_update_guild_badges(ctx.guild, update_rows, notify=False, log=False))
|
||||
|
||||
# Ack the deletion
|
||||
count = len(to_delete)
|
||||
roles = [ctx.guild.get_role(row.roleid) for row in to_delete]
|
||||
if count == len(guild_roles):
|
||||
await ctx.embed_reply("All study badges deleted.")
|
||||
log_embed = discord.Embed(
|
||||
title="Study badges cleared!",
|
||||
description="{} cleared the guild study badges. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
elif count == 1:
|
||||
badge_name = roles[0].name if roles[0] else strfdur(to_delete[0].required_time)
|
||||
await show_badge_list(
|
||||
ctx,
|
||||
desc="✅ Removed the **{}** badge.".format(badge_name)
|
||||
)
|
||||
log_embed = discord.Embed(
|
||||
title="Study badge removed!",
|
||||
description="{} removed the badge **{}**. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
badge_name,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
else:
|
||||
await show_badge_list(
|
||||
ctx,
|
||||
desc="✅ `{}` badges removed.".format(count)
|
||||
)
|
||||
log_embed = discord.Embed(
|
||||
title="Study badges removed!",
|
||||
description="{} removed `{}` badges. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
count,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
|
||||
# Post to the event log
|
||||
event_log = GuildSettings(ctx.guild.id).event_log.value
|
||||
if event_log:
|
||||
# TODO Error handling? Or improve the post method?
|
||||
log_embed.timestamp = datetime.datetime.utcnow()
|
||||
log_embed.colour = discord.Colour.orange()
|
||||
await event_log.send(embed=log_embed)
|
||||
|
||||
# Delete the roles (after asking first)
|
||||
roles = [role for role in roles if role is not None]
|
||||
if roles:
|
||||
if await ctx.ask("Do you also want to remove the associated guild roles?"):
|
||||
tasks = [
|
||||
asyncio.create_task(role.delete()) for role in roles
|
||||
]
|
||||
results = await asyncio.gather(
|
||||
*tasks,
|
||||
return_exceptions=True
|
||||
)
|
||||
bad_roles = [role for role, task in zip(roles, tasks) if task.exception()]
|
||||
if bad_roles:
|
||||
await ctx.embed_reply(
|
||||
"Couldn't delete the following roles:\n{}".format(
|
||||
'\n'.join(bad_role.mention for bad_role in bad_roles)
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply("Deleted `{}` roles.".format(len(roles)))
|
||||
elif ctx.args:
|
||||
# Ensure admin perms for modification
|
||||
await ensure_admin(ctx)
|
||||
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Parse the input
|
||||
lines = ctx.args.splitlines()
|
||||
results = [await parse_level(ctx, line) for line in lines]
|
||||
# Check for duplicates
|
||||
_set = set()
|
||||
duplicate = next((time for time, _ in results if time in _set or _set.add(time)), None)
|
||||
if duplicate:
|
||||
return await ctx.error_reply(
|
||||
"Level `{}` provided twice!".format(strfdur(duplicate, short=False))
|
||||
)
|
||||
current_times = set(row.required_time for row in guild_roles)
|
||||
|
||||
# Split up the provided lines into levels to add and levels to edit
|
||||
to_add = [result for result in results if result[0] not in current_times]
|
||||
to_edit = [result for result in results if result[0] in current_times]
|
||||
|
||||
# Apply changes to database
|
||||
if to_add:
|
||||
study_badges.insert_many(
|
||||
*((ctx.guild.id, time, role.id) for time, role in to_add),
|
||||
insert_keys=('guildid', 'required_time', 'roleid')
|
||||
)
|
||||
if to_edit:
|
||||
study_badges.update_many(
|
||||
*((role.id, ctx.guild.id, time) for time, role in to_edit),
|
||||
set_keys=('roleid',),
|
||||
where_keys=('guildid', 'required_time')
|
||||
)
|
||||
|
||||
# Also update the cached guild roles
|
||||
guild_role_cache.pop((ctx.guild.id, ), None)
|
||||
study_badges.queries.for_guild(ctx.guild.id)
|
||||
|
||||
# Ack changes
|
||||
if to_add and to_edit:
|
||||
desc = "{tick} `{num_add}` badges added and `{num_edit}` updated."
|
||||
elif to_add:
|
||||
desc = "{tick} `{num_add}` badges added."
|
||||
elif to_edit:
|
||||
desc = "{tick} `{num_edit}` badges updated."
|
||||
|
||||
desc = desc.format(
|
||||
tick='✅',
|
||||
num_add=len(to_add),
|
||||
num_edit=len(to_edit)
|
||||
)
|
||||
|
||||
await show_badge_list(ctx, desc)
|
||||
|
||||
# Count members who need new study badges
|
||||
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
|
||||
update_count = new_study_badges.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
if not update_count:
|
||||
# No-one needs updating
|
||||
return
|
||||
|
||||
if update_count > 20:
|
||||
# Confirm whether we want to update now
|
||||
resp = await ctx.ask(
|
||||
"`{}` members need their study badge roles updated, "
|
||||
"which will occur automatically for each member when they next study.\n"
|
||||
"Do you want to refresh the roles immediately instead? This may take a while!"
|
||||
)
|
||||
if not resp:
|
||||
return
|
||||
|
||||
# Fetch actual update rows
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
# Update data first
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Then apply the role updates and send notifications as usual
|
||||
await _update_guild_badges(ctx.guild, update_rows)
|
||||
|
||||
# TODO: Progress bar? Probably not needed since we have the event log
|
||||
# TODO: Ask about notifications?
|
||||
else:
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Just view the current study levels
|
||||
if not guild_roles:
|
||||
return await ctx.reply("There are no study badges set up!")
|
||||
|
||||
# TODO: You are at... this much to next level..
|
||||
await show_badge_list(ctx, guild_roles=guild_roles)
|
||||
|
||||
|
||||
async def parse_level(ctx, line):
|
||||
line = line.strip()
|
||||
|
||||
if ',' in line:
|
||||
splits = [split.strip() for split in line.split(',', maxsplit=1)]
|
||||
elif line.startswith('"') and '"' in line[1:]:
|
||||
splits = [split.strip() for split in line[1:].split('"', maxsplit=1)]
|
||||
else:
|
||||
splits = [split.strip() for split in line.split(maxsplit=1)]
|
||||
|
||||
if not line or len(splits) != 2 or not splits[1][0].isdigit():
|
||||
raise SafeCancellation(
|
||||
"**Level Syntax:** `<role>, <required_time>`, for example `Lion Cub, 200h`."
|
||||
)
|
||||
|
||||
if splits[1].isdigit():
|
||||
# No units! Assume hours
|
||||
time = int(splits[1]) * 3600
|
||||
else:
|
||||
time = parse_dur(splits[1])
|
||||
|
||||
role_str = splits[0]
|
||||
# TODO maybe add Y.. yes to all
|
||||
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
|
||||
return time, role
|
||||
|
||||
|
||||
async def ensure_admin(ctx):
|
||||
if not is_guild_admin(ctx.author):
|
||||
raise SafeCancellation("Only guild admins can modify the server study badges!")
|
||||
|
||||
|
||||
async def show_badge_list(ctx, desc=None, guild_roles=None):
|
||||
if guild_roles is None:
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Generate the time range strings
|
||||
time_strings = []
|
||||
first_time = guild_roles[0].required_time
|
||||
if first_time == 0:
|
||||
prev_time_str = '0'
|
||||
prev_time_hour = False
|
||||
else:
|
||||
prev_time_str = strfdur(guild_roles[0].required_time)
|
||||
prev_time_hour = not (guild_roles[0].required_time % 3600)
|
||||
for row in guild_roles[1:]:
|
||||
time = row.required_time
|
||||
time_str = strfdur(time)
|
||||
time_hour = not (time % 3600)
|
||||
if time_hour and prev_time_hour:
|
||||
time_strings.append(
|
||||
"{} - {}".format(prev_time_str[:-1], time_str)
|
||||
)
|
||||
else:
|
||||
time_strings.append(
|
||||
"{} - {}".format(prev_time_str, time_str)
|
||||
)
|
||||
prev_time_str = time_str
|
||||
prev_time_hour = time_hour
|
||||
time_strings.append(
|
||||
"≥ {}".format(prev_time_str)
|
||||
)
|
||||
|
||||
# Pair the time strings with their roles
|
||||
pairs = [
|
||||
(time_string, row.roleid)
|
||||
for time_string, row in zip(time_strings, guild_roles)
|
||||
]
|
||||
|
||||
# pairs = [
|
||||
# (strfdur(row.required_time), row.study_role)
|
||||
# for row in guild_roles
|
||||
# ]
|
||||
|
||||
# Split the pairs into blocks
|
||||
pair_blocks = [pairs[i:i+10] for i in range(0, len(pairs), 10)]
|
||||
|
||||
# Format the blocks into strings
|
||||
blocks = []
|
||||
for i, pair_block in enumerate(pair_blocks):
|
||||
dig_len = (i * 10 + len(pair_block)) // 10 + 1
|
||||
blocks.append('\n'.join(
|
||||
"`[{:<{}}]` | <@&{}> **({})**".format(
|
||||
i * 10 + j + 1,
|
||||
dig_len,
|
||||
role,
|
||||
time_string,
|
||||
) for j, (time_string, role) in enumerate(pair_block)
|
||||
))
|
||||
|
||||
# Compile the strings into pages
|
||||
pages = [
|
||||
discord.Embed(
|
||||
title="Study Badges in {}! \nStudy more to rank up!".format(ctx.guild.name),
|
||||
description="{}\n\n{}".format(desc, block) if desc else block
|
||||
) for block in blocks
|
||||
]
|
||||
|
||||
# Output and page the pages
|
||||
return await ctx.pager(pages)
|
||||
4
bot/modules/pending-rewrite/study/module.py
Normal file
4
bot/modules/pending-rewrite/study/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Study_Tracking")
|
||||
448
bot/modules/pending-rewrite/study/timers/Timer.py
Normal file
448
bot/modules/pending-rewrite/study/timers/Timer.py
Normal file
@@ -0,0 +1,448 @@
|
||||
import math
|
||||
import asyncio
|
||||
import discord
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
|
||||
from utils.lib import utc_now
|
||||
from utils.interactive import discord_shield
|
||||
from meta import client
|
||||
from settings import GuildSettings
|
||||
from data.conditions import THIS_SHARD
|
||||
|
||||
|
||||
from ..module import module
|
||||
|
||||
from .data import timers as timer_table
|
||||
|
||||
|
||||
Stage = namedtuple('Stage', ['name', 'start', 'duration', 'end'])
|
||||
|
||||
|
||||
class Timer:
|
||||
timers = {} # channelid -> Timer
|
||||
|
||||
def __init__(self, channelid):
|
||||
self.channelid = channelid
|
||||
self.last_seen = {
|
||||
} # Memberid -> timestamps
|
||||
|
||||
self.reaction_message = None
|
||||
|
||||
self._state = None
|
||||
self._last_voice_update = None
|
||||
|
||||
self._voice_update_task = None
|
||||
self._run_task = None
|
||||
self._runloop_task = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, channel, focus_length, break_length, **kwargs):
|
||||
timer_table.create_row(
|
||||
channelid=channel.id,
|
||||
guildid=channel.guild.id,
|
||||
focus_length=focus_length,
|
||||
break_length=break_length,
|
||||
last_started=kwargs.pop('last_started', utc_now()),
|
||||
**kwargs
|
||||
)
|
||||
return cls(channel.id)
|
||||
|
||||
@classmethod
|
||||
def fetch_timer(cls, channelid):
|
||||
return cls.timers.get(channelid, None)
|
||||
|
||||
@classmethod
|
||||
def fetch_guild_timers(cls, guildid):
|
||||
timers = []
|
||||
guild = client.get_guild(guildid)
|
||||
if guild:
|
||||
for channel in guild.voice_channels:
|
||||
if (timer := cls.timers.get(channel.id, None)):
|
||||
timers.append(timer)
|
||||
|
||||
return timers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return timer_table.fetch(self.channelid)
|
||||
|
||||
@property
|
||||
def focus_length(self):
|
||||
return self.data.focus_length
|
||||
|
||||
@property
|
||||
def break_length(self):
|
||||
return self.data.break_length
|
||||
|
||||
@property
|
||||
def inactivity_threshold(self):
|
||||
return self.data.inactivity_threshold or 3
|
||||
|
||||
@property
|
||||
def current_stage(self):
|
||||
if (last_start := self.data.last_started) is None:
|
||||
# Timer hasn't been started
|
||||
return None
|
||||
now = utc_now()
|
||||
diff = (now - last_start).total_seconds()
|
||||
diff %= (self.focus_length + self.break_length)
|
||||
if diff > self.focus_length:
|
||||
return Stage(
|
||||
'BREAK',
|
||||
now - timedelta(seconds=(diff - self.focus_length)),
|
||||
self.break_length,
|
||||
now + timedelta(seconds=(- diff + self.focus_length + self.break_length))
|
||||
)
|
||||
else:
|
||||
return Stage(
|
||||
'FOCUS',
|
||||
now - timedelta(seconds=diff),
|
||||
self.focus_length,
|
||||
now + timedelta(seconds=(self.focus_length - diff))
|
||||
)
|
||||
|
||||
@property
|
||||
def guild(self):
|
||||
return client.get_guild(self.data.guildid)
|
||||
|
||||
@property
|
||||
def channel(self):
|
||||
return client.get_channel(self.channelid)
|
||||
|
||||
@property
|
||||
def text_channel(self):
|
||||
if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)):
|
||||
return channel
|
||||
else:
|
||||
return GuildSettings(self.data.guildid).pomodoro_channel.value
|
||||
|
||||
@property
|
||||
def members(self):
|
||||
if (channel := self.channel):
|
||||
return [member for member in channel.members if not member.bot]
|
||||
else:
|
||||
return []
|
||||
|
||||
@property
|
||||
def channel_name(self):
|
||||
"""
|
||||
Current name for the voice channel
|
||||
"""
|
||||
stage = self.current_stage
|
||||
name_format = self.data.channel_name or "{remaining} {stage} -- {name}"
|
||||
name = name_format.replace(
|
||||
'{remaining}', "{}m".format(
|
||||
int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)),
|
||||
)
|
||||
).replace(
|
||||
'{stage}', stage.name.lower()
|
||||
).replace(
|
||||
'{members}', str(len(self.channel.members))
|
||||
).replace(
|
||||
'{name}', self.data.pretty_name or "WORK ROOM"
|
||||
).replace(
|
||||
'{pattern}',
|
||||
"{}/{}".format(
|
||||
int(self.focus_length // 60), int(self.break_length // 60)
|
||||
)
|
||||
)
|
||||
return name[:100]
|
||||
|
||||
async def notify_change_stage(self, old_stage, new_stage):
|
||||
# Update channel name
|
||||
asyncio.create_task(self._update_channel_name())
|
||||
|
||||
# Kick people if they need kicking
|
||||
to_warn = []
|
||||
to_kick = []
|
||||
warn_threshold = (self.inactivity_threshold - 1) * (self.break_length + self.focus_length)
|
||||
kick_threshold = self.inactivity_threshold * (self.break_length + self.focus_length)
|
||||
for member in self.members:
|
||||
if member.id in self.last_seen:
|
||||
diff = (utc_now() - self.last_seen[member.id]).total_seconds()
|
||||
if diff >= kick_threshold:
|
||||
to_kick.append(member)
|
||||
elif diff > warn_threshold:
|
||||
to_warn.append(member)
|
||||
else:
|
||||
# Shouldn't really happen, but
|
||||
self.last_seen[member.id] = utc_now()
|
||||
|
||||
content = []
|
||||
|
||||
if to_kick:
|
||||
# Do kick
|
||||
await asyncio.gather(
|
||||
*(member.edit(voice_channel=None) for member in to_kick),
|
||||
return_exceptions=True
|
||||
)
|
||||
kick_string = (
|
||||
"**Kicked due to inactivity:** {}".format(', '.join(member.mention for member in to_kick))
|
||||
)
|
||||
content.append(kick_string)
|
||||
|
||||
if to_warn:
|
||||
warn_string = (
|
||||
"**Please react to avoid being kicked:** {}".format(
|
||||
', '.join(member.mention for member in to_warn)
|
||||
)
|
||||
)
|
||||
content.append(warn_string)
|
||||
|
||||
# Send a new status/reaction message
|
||||
if self.text_channel and self.members:
|
||||
old_reaction_message = self.reaction_message
|
||||
|
||||
# Send status image, add reaction
|
||||
args = await self.status()
|
||||
if status_content := args.pop('content', None):
|
||||
content.append(status_content)
|
||||
self.reaction_message = await self.text_channel.send(
|
||||
content='\n'.join(content),
|
||||
**args
|
||||
)
|
||||
await self.reaction_message.add_reaction('✅')
|
||||
|
||||
if old_reaction_message:
|
||||
asyncio.create_task(discord_shield(old_reaction_message.delete()))
|
||||
|
||||
# Ping people
|
||||
members = self.members
|
||||
blocks = [
|
||||
''.join(member.mention for member in members[i:i+90])
|
||||
for i in range(0, len(members), 90)
|
||||
]
|
||||
await asyncio.gather(
|
||||
*(self.text_channel.send(block, delete_after=0.5) for block in blocks),
|
||||
return_exceptions=True
|
||||
)
|
||||
elif not self.members:
|
||||
await self.update_last_status()
|
||||
# TODO: DM task if anyone has notifications on
|
||||
|
||||
# Mute or unmute everyone in the channel as needed
|
||||
# Not possible, due to Discord restrictions
|
||||
# overwrite = self.channel.overwrites_for(self.channel.guild.default_role)
|
||||
# overwrite.speak = (new_stage.name == 'BREAK')
|
||||
# try:
|
||||
# await self.channel.set_permissions(
|
||||
# self.channel.guild.default_role,
|
||||
# overwrite=overwrite
|
||||
# )
|
||||
# except discord.HTTPException:
|
||||
# pass
|
||||
|
||||
# Run the notify hook
|
||||
await self.notify_hook(old_stage, new_stage)
|
||||
|
||||
async def notify_hook(self, old_stage, new_stage):
|
||||
"""
|
||||
May be overridden to provide custom actions during notification.
|
||||
For example, for voice alerts.
|
||||
"""
|
||||
...
|
||||
|
||||
async def _update_channel_name(self):
|
||||
# Attempt to update the voice channel name
|
||||
# Ensures that only one update is pending at any time
|
||||
# Attempts to wait until the next viable channel update
|
||||
if self._voice_update_task:
|
||||
self._voice_update_task.cancel()
|
||||
|
||||
if not self.channel:
|
||||
return
|
||||
|
||||
if self.channel.name == self.channel_name:
|
||||
return
|
||||
|
||||
if not self.channel.permissions_for(self.channel.guild.me).manage_channels:
|
||||
return
|
||||
|
||||
if self._last_voice_update:
|
||||
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
|
||||
if to_wait > 0:
|
||||
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait))
|
||||
try:
|
||||
await self._voice_update_task
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
self._voice_update_task = asyncio.create_task(
|
||||
self.channel.edit(name=self.channel_name)
|
||||
)
|
||||
try:
|
||||
await self._voice_update_task
|
||||
self._last_voice_update = utc_now()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def status(self):
|
||||
"""
|
||||
Returns argument dictionary compatible with `discord.Channel.send`.
|
||||
"""
|
||||
# Generate status message
|
||||
stage = self.current_stage
|
||||
stage_str = "**{}** minutes focus with **{}** minutes break".format(
|
||||
self.focus_length // 60,
|
||||
self.break_length // 60
|
||||
)
|
||||
remaining = (stage.end - utc_now()).total_seconds()
|
||||
|
||||
memberstr = ', '.join(member.mention for member in self.members[:20])
|
||||
if len(self.members) > 20:
|
||||
memberstr += '...'
|
||||
|
||||
description = (
|
||||
("{}: {}\n"
|
||||
"Currently in `{}`, with `{:02}:{:02}` remaining.\n"
|
||||
"{}").format(
|
||||
self.channel.mention,
|
||||
stage_str,
|
||||
stage.name,
|
||||
int(remaining // 3600),
|
||||
int((remaining // 60) % 60),
|
||||
memberstr
|
||||
)
|
||||
)
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=description
|
||||
)
|
||||
return {'embed': embed}
|
||||
|
||||
async def update_last_status(self):
|
||||
"""
|
||||
Update the last posted status message, if it exists.
|
||||
"""
|
||||
args = await self.status()
|
||||
repost = True
|
||||
if self.reaction_message:
|
||||
try:
|
||||
await self.reaction_message.edit(**args)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
else:
|
||||
repost = False
|
||||
|
||||
if repost and self.text_channel:
|
||||
try:
|
||||
self.reaction_message = await self.text_channel.send(**args)
|
||||
await self.reaction_message.add_reaction('✅')
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
async def destroy(self):
|
||||
"""
|
||||
Remove the timer.
|
||||
"""
|
||||
# Remove timer from cache
|
||||
self.timers.pop(self.channelid, None)
|
||||
|
||||
# Cancel the loop
|
||||
if self._run_task:
|
||||
self._run_task.cancel()
|
||||
|
||||
# Delete the reaction message
|
||||
if self.reaction_message:
|
||||
try:
|
||||
await self.reaction_message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Remove the timer from data
|
||||
timer_table.delete_where(channelid=self.channelid)
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Runloop
|
||||
"""
|
||||
timer = self.timers.pop(self.channelid, None)
|
||||
if timer and timer._run_task:
|
||||
timer._run_task.cancel()
|
||||
self.timers[self.channelid] = self
|
||||
|
||||
if not self.data.last_started:
|
||||
self.data.last_started = utc_now()
|
||||
asyncio.create_task(self.notify_change_stage(None, self.current_stage))
|
||||
|
||||
while True:
|
||||
stage = self._state = self.current_stage
|
||||
to_next_stage = (stage.end - utc_now()).total_seconds()
|
||||
|
||||
# Allow updating with 10 seconds of drift to stage change
|
||||
if to_next_stage > 10 * 60 - 10:
|
||||
time_to_sleep = 5 * 60
|
||||
else:
|
||||
time_to_sleep = to_next_stage
|
||||
|
||||
self._run_task = asyncio.create_task(asyncio.sleep(time_to_sleep))
|
||||
try:
|
||||
await self._run_task
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
# Destroy the timer if our voice channel no longer exists
|
||||
if not self.channel:
|
||||
await self.destroy()
|
||||
break
|
||||
|
||||
if self._state.end < utc_now():
|
||||
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
||||
elif self.members:
|
||||
asyncio.create_task(self._update_channel_name())
|
||||
asyncio.create_task(self.update_last_status())
|
||||
|
||||
def runloop(self):
|
||||
self._runloop_task = asyncio.create_task(self.run())
|
||||
|
||||
|
||||
# Loading logic
|
||||
@module.launch_task
|
||||
async def load_timers(client):
|
||||
timer_rows = timer_table.fetch_rows_where(
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
count = 0
|
||||
for row in timer_rows:
|
||||
if client.get_channel(row.channelid):
|
||||
# Channel exists
|
||||
# Create the timer
|
||||
timer = Timer(row.channelid)
|
||||
|
||||
# Populate the members
|
||||
timer.last_seen = {
|
||||
member.id: utc_now()
|
||||
for member in timer.members
|
||||
}
|
||||
|
||||
# Start the timer
|
||||
timer.runloop()
|
||||
count += 1
|
||||
|
||||
client.log(
|
||||
"Loaded and start '{}' timers!".format(count),
|
||||
context="TIMERS"
|
||||
)
|
||||
|
||||
|
||||
# Hooks
|
||||
@client.add_after_event('raw_reaction_add')
|
||||
async def reaction_tracker(client, payload):
|
||||
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice:
|
||||
if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)):
|
||||
if timer.reaction_message and payload.message_id == timer.reaction_message.id:
|
||||
timer.last_seen[payload.member.id] = utc_now()
|
||||
|
||||
|
||||
@client.add_after_event('voice_state_update')
|
||||
async def touch_member(client, member, before, after):
|
||||
if not member.bot and after.channel != before.channel:
|
||||
if after.channel and (timer := Timer.fetch_timer(after.channel.id)):
|
||||
timer.last_seen[member.id] = utc_now()
|
||||
await timer.update_last_status()
|
||||
|
||||
if before.channel and (timer := Timer.fetch_timer(before.channel.id)):
|
||||
timer.last_seen.pop(member.id, None)
|
||||
await timer.update_last_status()
|
||||
3
bot/modules/pending-rewrite/study/timers/__init__.py
Normal file
3
bot/modules/pending-rewrite/study/timers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .Timer import Timer
|
||||
from . import commands
|
||||
from . import settings
|
||||
460
bot/modules/pending-rewrite/study/timers/commands.py
Normal file
460
bot/modules/pending-rewrite/study/timers/commands.py
Normal file
@@ -0,0 +1,460 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from cmdClient.checks import in_guild
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from wards import guild_admin
|
||||
from utils.lib import utc_now, tick, prop_tabulate
|
||||
|
||||
from ..module import module
|
||||
|
||||
from .Timer import Timer
|
||||
|
||||
|
||||
config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
|
||||
MAX_TIMERS_PER_GUILD = 10
|
||||
|
||||
options = {
|
||||
"--name": "The timer name (as shown in alerts and `{prefix}timer`).",
|
||||
"--channelname": "The name of the voice channel, see below for substitutions.",
|
||||
"--threshold": "How many focus+break cycles before a member is kicked.",
|
||||
"--text": "Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`)."
|
||||
}
|
||||
options_str = prop_tabulate(*zip(*options.items()))
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"timer",
|
||||
group="🆕 Pomodoro",
|
||||
desc="View your study room timer.",
|
||||
flags=config_flags,
|
||||
aliases=('timers',)
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_timer(ctx: Context, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}timer
|
||||
{prefix}timers
|
||||
Description:
|
||||
Display your current study room timer status.
|
||||
If you aren't in a study room, instead shows a list of timers you can join.
|
||||
Use `{prefix}timers` to always show the list of timers instead.
|
||||
"""
|
||||
channel = ctx.author.voice.channel if ctx.author.voice and ctx.alias.lower() != 'timers' else None
|
||||
if ctx.args:
|
||||
if len(ctx.args.split()) > 1:
|
||||
# Multiple arguments provided
|
||||
# Assume configuration attempt
|
||||
return await _pomo_admin(ctx, flags)
|
||||
else:
|
||||
# Single argument provided, assume channel reference
|
||||
channel = await ctx.find_channel(
|
||||
ctx.args,
|
||||
interactive=True,
|
||||
chan_type=discord.ChannelType.voice,
|
||||
)
|
||||
if channel is None:
|
||||
return
|
||||
if channel is None:
|
||||
# Author is not in a voice channel, and they did not select a channel
|
||||
# Display the server timers they can see
|
||||
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
||||
timers = [
|
||||
timer for timer in timers
|
||||
if timer.channel and timer.channel.permissions_for(ctx.author).view_channel
|
||||
]
|
||||
if not timers:
|
||||
if await guild_admin.run(ctx):
|
||||
return await ctx.error_reply(
|
||||
"No timers are running yet!\n"
|
||||
f"Start a timer by joining a voice channel and running e.g. `{ctx.best_prefix}pomodoro 50, 10`.\n"
|
||||
f"See `{ctx.best_prefix}help pomodoro for detailed usage."
|
||||
)
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"No timers are running!\n"
|
||||
f"You can ask an admin to start one using `{ctx.best_prefix}pomodoro`."
|
||||
)
|
||||
# Build a summary list
|
||||
timer_strings = []
|
||||
for timer in timers:
|
||||
stage = timer.current_stage
|
||||
stage_str = "(**`{}m`** focus, **`{}m`** break)".format(
|
||||
int(timer.focus_length // 60), int(timer.break_length // 60)
|
||||
)
|
||||
if len(timer.members) > 1:
|
||||
member_str = "**{}** members are ".format(len(timer.members))
|
||||
elif len(timer.members) == 1:
|
||||
member_str = "{} is ".format(timer.members[0].mention)
|
||||
else:
|
||||
member_str = ""
|
||||
remaining = (stage.end - utc_now()).total_seconds()
|
||||
|
||||
timer_strings.append(
|
||||
("{} {}\n"
|
||||
"{}urrently **{}** with `{:02}:{:02}` left.").format(
|
||||
timer.channel.mention,
|
||||
stage_str,
|
||||
member_str + 'c' if member_str else 'C',
|
||||
"focusing" if stage.name == "FOCUS" else "resting",
|
||||
int(remaining // 3600),
|
||||
int((remaining // 60) % 60),
|
||||
)
|
||||
)
|
||||
|
||||
blocks = [
|
||||
'\n\n'.join(timer_strings[i:i+10])
|
||||
for i in range(0, len(timer_strings), 10)
|
||||
]
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
title="Study Timers",
|
||||
description=block,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
for block in blocks
|
||||
]
|
||||
await ctx.pager(embeds)
|
||||
else:
|
||||
# We have a channel
|
||||
# Get the associated timer
|
||||
timer = Timer.fetch_timer(channel.id)
|
||||
if timer is None:
|
||||
# No timer in this channel
|
||||
return await ctx.error_reply(
|
||||
f"{channel.mention} doesn't have a timer running!"
|
||||
)
|
||||
else:
|
||||
# We have a timer
|
||||
# Show the timer status
|
||||
await ctx.reply(**await timer.status())
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"pomodoro",
|
||||
group="Pomodoro",
|
||||
desc="Add and configure timers for your study rooms.",
|
||||
flags=config_flags
|
||||
)
|
||||
async def cmd_pomodoro(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}pomodoro [channelid] <focus time>, <break time> [channel name]
|
||||
{prefix}pomodoro [channelid] [options]
|
||||
{prefix}pomodoro [channelid] delete
|
||||
Description:
|
||||
Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`.
|
||||
The timer will start automatically and continue forever.
|
||||
See the options and examples below for configuration.
|
||||
Options::
|
||||
--name: The timer name (as shown in alerts and `{prefix}timer`).
|
||||
--channelname: The name of the voice channel, see below for substitutions.
|
||||
--threshold: How many focus+break cycles before a member is kicked.
|
||||
--text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`).
|
||||
Channel name substitutions::
|
||||
{{remaining}}: The time left in the current focus or break session, e.g. `10m`.
|
||||
{{stage}}: The name of the current stage (`focus` or `break`).
|
||||
{{name}}: The configured timer name.
|
||||
{{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`).
|
||||
Examples:
|
||||
Add a timer to your study room with `50` minutes focus, `10` minutes break.
|
||||
> `{prefix}pomodoro 50, 10`
|
||||
Add a timer with a custom updating channel name
|
||||
> `{prefix}pomodoro 50, 10 {{remaining}} {{stage}} -- {{pattern}} room`
|
||||
Change the name on the `{prefix}timer` status
|
||||
> `{prefix}pomodoro --name 50/10 study room`
|
||||
Change the updating channel name
|
||||
> `{prefix}pomodoro --channelname {{remaining}} left -- {{name}}`
|
||||
"""
|
||||
await _pomo_admin(ctx, flags)
|
||||
|
||||
|
||||
async def _pomo_admin(ctx, flags):
|
||||
# Extract target channel
|
||||
if ctx.author.voice:
|
||||
channel = ctx.author.voice.channel
|
||||
else:
|
||||
channel = None
|
||||
|
||||
args = ctx.args
|
||||
if ctx.args:
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
assume_channel = not (',' in splits[0])
|
||||
assume_channel = assume_channel and not (channel and len(splits[0]) < 5)
|
||||
assume_channel = assume_channel or (splits[0].strip('#<>').isdigit() and len(splits[0]) > 10)
|
||||
if assume_channel:
|
||||
# Assume first argument is a channel specifier
|
||||
channel = await ctx.find_channel(
|
||||
splits[0], interactive=True, chan_type=discord.ChannelType.voice
|
||||
)
|
||||
if not channel:
|
||||
# Invalid channel provided
|
||||
# find_channel already gave a message, just return silently
|
||||
return
|
||||
args = splits[1] if len(splits) > 1 else ""
|
||||
|
||||
if not args and not any(flags.values()):
|
||||
# No arguments given to the `pomodoro` command.
|
||||
# TODO: If we have a channel, replace this with timer setting information
|
||||
return await ctx.error_reply(
|
||||
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
|
||||
)
|
||||
|
||||
if not channel:
|
||||
return await ctx.error_reply(
|
||||
f"No channel specified!\n"
|
||||
"Please join a voice channel or pass the channel id as the first argument.\n"
|
||||
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
|
||||
)
|
||||
|
||||
# Now we have a channel and configuration arguments
|
||||
# Next check the user has authority to modify the timer
|
||||
if not await guild_admin.run(ctx):
|
||||
# TODO: The channel is a room they own?
|
||||
return await ctx.error_reply(
|
||||
"You need to be a guild admin to set up the pomodoro timers!"
|
||||
)
|
||||
|
||||
# Get the associated timer, if it exists
|
||||
timer = Timer.fetch_timer(channel.id)
|
||||
|
||||
# Parse required action
|
||||
if args.lower() == 'delete':
|
||||
if timer:
|
||||
await timer.destroy()
|
||||
await ctx.embed_reply(
|
||||
"Destroyed the timer in {}.".format(channel.mention)
|
||||
)
|
||||
else:
|
||||
await ctx.error_reply(
|
||||
"{} doesn't have a timer to delete!".format(channel.mention)
|
||||
)
|
||||
elif args or timer:
|
||||
if args:
|
||||
# Any provided arguments should be for setting up a new timer pattern
|
||||
# Check the pomodoro channel exists
|
||||
if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value:
|
||||
return await ctx.error_reply(
|
||||
"Please set the pomodoro alerts channel first, "
|
||||
f"with `{ctx.best_prefix}config pomodoro_channel <channel>`.\n"
|
||||
f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}"
|
||||
)
|
||||
# First validate input
|
||||
try:
|
||||
# Ensure no trailing commas
|
||||
args = args.strip(',')
|
||||
if ',' not in args:
|
||||
raise SafeCancellation("Couldn't parse work and break times!")
|
||||
|
||||
timesplits = args.split(',', maxsplit=1)
|
||||
if not timesplits[0].isdigit() or len(timesplits[0]) > 3:
|
||||
raise SafeCancellation(f"Couldn't parse the provided work period length `{timesplits[0]}`.")
|
||||
|
||||
breaksplits = timesplits[1].split(maxsplit=1)
|
||||
if not breaksplits[0].isdigit() or len(breaksplits[0]) > 3:
|
||||
raise SafeCancellation(f"Couldn't parse the provided break period length `{breaksplits[0]}`.")
|
||||
except SafeCancellation as e:
|
||||
usage = discord.Embed(
|
||||
title="Couldn't understand arguments!",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
usage.add_field(
|
||||
name="Usage",
|
||||
value=(
|
||||
f"`{ctx.best_prefix}{ctx.alias} [channelid] <work time>, <break time> [channel name template]"
|
||||
)
|
||||
)
|
||||
usage.add_field(
|
||||
name="Examples",
|
||||
value=(
|
||||
f"`{ctx.best_prefix}{ctx.alias} 50, 10`\n"
|
||||
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10`\n"
|
||||
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10 {{remaining}} - {channel.name}`\n"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
usage.set_footer(
|
||||
text=f"For detailed usage and examples see {ctx.best_prefix}help pomodoro"
|
||||
)
|
||||
if e.msg:
|
||||
usage.description = e.msg
|
||||
return await ctx.reply(embed=usage)
|
||||
|
||||
# Input validation complete, assign values
|
||||
focus_length = int(timesplits[0])
|
||||
break_length = int(breaksplits[0])
|
||||
channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None
|
||||
|
||||
# Check the stages aren't too short
|
||||
if focus_length < 5:
|
||||
return await ctx.error_reply("The focus duration must be at least 5 minutes!")
|
||||
if break_length < 5:
|
||||
return await ctx.error_reply("The break duration must be at least 5 minutes!")
|
||||
|
||||
# Create or update the timer
|
||||
if not timer:
|
||||
# Create timer
|
||||
# First check number of timers
|
||||
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
||||
if len(timers) >= MAX_TIMERS_PER_GUILD:
|
||||
return await ctx.error_reply(
|
||||
"Cannot create another timer!\n"
|
||||
"This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD)
|
||||
)
|
||||
# First check permissions
|
||||
if not channel.permissions_for(ctx.guild.me).send_messages:
|
||||
embed = discord.Embed(
|
||||
title="Could not create timer!",
|
||||
description=f"I do not have sufficient guild permissions to join {channel.mention}!",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
return await ctx.reply(embed=embed)
|
||||
|
||||
# Create timer
|
||||
timer = Timer.create(
|
||||
channel,
|
||||
focus_length * 60,
|
||||
break_length * 60,
|
||||
channel_name=channelname or None,
|
||||
pretty_name=channel.name
|
||||
)
|
||||
timer.last_seen = {
|
||||
member.id: utc_now()
|
||||
for member in timer.members
|
||||
}
|
||||
timer.runloop()
|
||||
|
||||
# Post a new status message
|
||||
await timer.update_last_status()
|
||||
else:
|
||||
# Update timer and restart
|
||||
stage = timer.current_stage
|
||||
|
||||
timer.last_seen = {
|
||||
member.id: utc_now()
|
||||
for member in timer.members
|
||||
}
|
||||
|
||||
with timer.data.batch_update():
|
||||
timer.data.focus_length = focus_length * 60
|
||||
timer.data.break_length = break_length * 60
|
||||
timer.data.last_started = utc_now()
|
||||
if channelname:
|
||||
timer.data.channel_name = channelname
|
||||
|
||||
await timer.notify_change_stage(stage, timer.current_stage)
|
||||
timer.runloop()
|
||||
|
||||
# Ack timer creation
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Timer Started!",
|
||||
description=(
|
||||
f"Started a timer in {channel.mention} with **{focus_length}** "
|
||||
f"minutes focus and **{break_length}** minutes break."
|
||||
)
|
||||
)
|
||||
embed.add_field(
|
||||
name="Further configuration",
|
||||
value=(
|
||||
"Use `{prefix}{ctx.alias} --setting value` to configure your new timer.\n"
|
||||
"*Replace `--setting` with one of the below settings, "
|
||||
"please see `{prefix}help pomodoro` for examples.*\n"
|
||||
f"{options_str.format(prefix=ctx.best_prefix)}"
|
||||
).format(prefix=ctx.best_prefix, ctx=ctx, channel=channel)
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
to_set = []
|
||||
if flags['name']:
|
||||
# Handle name update
|
||||
to_set.append((
|
||||
'pretty_name',
|
||||
flags['name'],
|
||||
f"The timer will now appear as `{flags['name']}` in the status."
|
||||
))
|
||||
if flags['threshold']:
|
||||
# Handle threshold update
|
||||
if not flags['threshold'].isdigit():
|
||||
return await ctx.error_reply(
|
||||
"The provided threshold must be a number!"
|
||||
)
|
||||
to_set.append((
|
||||
'inactivity_threshold',
|
||||
int(flags['threshold']),
|
||||
"Members will be unsubscribed after being inactive for more than `{}` focus+break stages.".format(
|
||||
flags['threshold']
|
||||
)
|
||||
))
|
||||
if flags['channelname']:
|
||||
# Handle channel name update
|
||||
to_set.append((
|
||||
'channel_name',
|
||||
flags['channelname'],
|
||||
f"The voice channel name template is now `{flags['channelname']}`."
|
||||
))
|
||||
if flags['text']:
|
||||
# Handle text channel update
|
||||
flag = flags['text']
|
||||
if flag.lower() == 'none':
|
||||
# Check if there is a default channel
|
||||
channel = ctx.guild_settings.pomodoro_channel.value
|
||||
if channel:
|
||||
# Unset the channel to the default
|
||||
msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})"
|
||||
to_set.append((
|
||||
'text_channelid',
|
||||
None,
|
||||
msg
|
||||
))
|
||||
# Remove the last reaction message and send a new one
|
||||
timer.reaction_message = None
|
||||
# Ensure this happens after the data update
|
||||
asyncio.create_task(timer.update_last_status())
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"The text channel cannot be unset because there is no `pomodoro_channel` set up!\n"
|
||||
f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel."
|
||||
)
|
||||
else:
|
||||
# Attempt to parse the provided channel
|
||||
channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text)
|
||||
if channel:
|
||||
if not channel.permissions_for(ctx.guild.me).send_messages:
|
||||
return await ctx.error_reply(
|
||||
f"Cannot send pomodoro alerts to {channel.mention}! "
|
||||
"I don't have permission to send messages there."
|
||||
)
|
||||
to_set.append((
|
||||
'text_channelid',
|
||||
channel.id,
|
||||
f"Timer alerts and updates will now be sent to {channel.mention}."
|
||||
))
|
||||
# Remove the last reaction message and send a new one
|
||||
timer.reaction_message = None
|
||||
# Ensure this happens after the data update
|
||||
asyncio.create_task(timer.update_last_status())
|
||||
else:
|
||||
# Ack has already been sent, just ignore
|
||||
return
|
||||
|
||||
if to_set:
|
||||
to_update = {item[0]: item[1] for item in to_set}
|
||||
timer.data.update(**to_update)
|
||||
desc = '\n'.join(f"{tick} {item[2]}" for item in to_set)
|
||||
embed = discord.Embed(
|
||||
title=f"Timer option{'s' if len(to_update) > 1 else ''} updated!",
|
||||
description=desc,
|
||||
colour=discord.Colour.green()
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Flags were provided, but there is no timer, and no timer was created
|
||||
await ctx.error_reply(
|
||||
f"No timer exists in {channel.mention} to set up!\n"
|
||||
f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```"
|
||||
f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
|
||||
)
|
||||
15
bot/modules/pending-rewrite/study/timers/data.py
Normal file
15
bot/modules/pending-rewrite/study/timers/data.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from data import RowTable
|
||||
|
||||
|
||||
timers = RowTable(
|
||||
'timers',
|
||||
('channelid', 'guildid',
|
||||
'text_channelid',
|
||||
'focus_length', 'break_length',
|
||||
'inactivity_threshold',
|
||||
'last_started',
|
||||
'text_channelid',
|
||||
'channel_name', 'pretty_name'),
|
||||
'channelid',
|
||||
cache={}
|
||||
)
|
||||
47
bot/modules/pending-rewrite/study/timers/settings.py
Normal file
47
bot/modules/pending-rewrite/study/timers/settings.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
|
||||
from settings import GuildSettings, GuildSetting
|
||||
import settings
|
||||
|
||||
from . import Timer
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class pomodoro_channel(settings.TextChannel, GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "pomodoro_channel"
|
||||
_data_column = "pomodoro_channel"
|
||||
|
||||
display_name = "pomodoro_channel"
|
||||
desc = "Channel to send pomodoro timer status updates and alerts."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"Channel to send pomodoro status updates to.\n"
|
||||
"Members studying in rooms with an attached timer will need to be able to see "
|
||||
"this channel to get notifications and react to the status messages."
|
||||
)
|
||||
_accepts = "Any text channel I can write to, or `None` to unset."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
timers = Timer.fetch_guild_timers(self.id)
|
||||
if self.value:
|
||||
for timer in timers:
|
||||
if timer.reaction_message and timer.reaction_message.channel != self.value:
|
||||
timer.reaction_message = None
|
||||
asyncio.create_task(timer.update_last_status())
|
||||
return f"The pomodoro alerts and updates will now be sent to {self.value.mention}"
|
||||
else:
|
||||
deleted = 0
|
||||
for timer in timers:
|
||||
if not timer.text_channel:
|
||||
deleted += 1
|
||||
asyncio.create_task(timer.destroy())
|
||||
|
||||
msg = "The pomodoro alert channel has been unset."
|
||||
if deleted:
|
||||
msg += f" `{deleted}` timers were subsequently deactivated."
|
||||
return msg
|
||||
4
bot/modules/pending-rewrite/study/tracking/__init__.py
Normal file
4
bot/modules/pending-rewrite/study/tracking/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import data
|
||||
from . import settings
|
||||
from . import session_tracker
|
||||
from . import commands
|
||||
167
bot/modules/pending-rewrite/study/tracking/commands.py
Normal file
167
bot/modules/pending-rewrite/study/tracking/commands.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from cmdClient.checks import in_guild
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from core import Lion
|
||||
from wards import is_guild_admin
|
||||
|
||||
from ..module import module
|
||||
|
||||
|
||||
MAX_TAG_LENGTH = 10
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"now",
|
||||
group="🆕 Pomodoro",
|
||||
desc="What are you working on?",
|
||||
aliases=('studying', 'workingon'),
|
||||
flags=('clear', 'new')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_now(ctx: Context, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}now [tag]
|
||||
{prefix}now @mention
|
||||
{prefix}now --clear
|
||||
Description:
|
||||
Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`.
|
||||
Mention someone else to view what they are working on!
|
||||
Flags::
|
||||
clear: Remove your current tag.
|
||||
Examples:
|
||||
> {prefix}now Biology
|
||||
> {prefix}now {ctx.author.mention}
|
||||
"""
|
||||
if flags['clear']:
|
||||
if ctx.msg.mentions and is_guild_admin(ctx.author):
|
||||
# Assume an admin is trying to clear another user's tag
|
||||
for target in ctx.msg.mentions:
|
||||
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
if lion.session:
|
||||
lion.session.data.tag = None
|
||||
|
||||
if len(ctx.msg.mentions) == 1:
|
||||
await ctx.embed_reply(
|
||||
f"Cleared session tags for {ctx.msg.mentions[0].mention}."
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
f"Cleared session tags for:\n{', '.join(target.mention for target in ctx.msg.mentions)}."
|
||||
)
|
||||
else:
|
||||
# Assume the user is clearing their own session tag
|
||||
if (session := ctx.alion.session):
|
||||
session.data.tag = None
|
||||
await ctx.embed_reply(
|
||||
"Removed your session study tag!"
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"You aren't studying right now, so there is nothing to clear!"
|
||||
)
|
||||
elif ctx.args:
|
||||
if ctx.msg.mentions:
|
||||
# Assume peeking at user's current session
|
||||
|
||||
# Smoll easter egg
|
||||
target = ctx.msg.mentions[0]
|
||||
if target == ctx.guild.me:
|
||||
student_count, guild_count = ctx.client.data.current_sessions.select_one_where(
|
||||
select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(guildid)) AS guild_count"),
|
||||
)
|
||||
if ctx.alion.session:
|
||||
if (tag := ctx.alion.session.data.tag):
|
||||
tail = f"Good luck with your **{tag}**!"
|
||||
else:
|
||||
tail = "Good luck with your study, I believe in you!"
|
||||
else:
|
||||
tail = "Do you want to join? Hop in a study channel and let's get to work!"
|
||||
return await ctx.embed_reply(
|
||||
"Thanks for asking!\n"
|
||||
f"I'm just helping out the **{student_count}** "
|
||||
f"dedicated people currently working across **{guild_count}** fun communities!\n"
|
||||
f"{tail}"
|
||||
)
|
||||
|
||||
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
if not lion.session:
|
||||
await ctx.embed_reply(
|
||||
f"{target.mention} isn't working right now!"
|
||||
)
|
||||
else:
|
||||
duration = lion.session.duration
|
||||
if duration > 3600:
|
||||
dur_str = "{}h {}m".format(
|
||||
int(duration // 3600),
|
||||
int((duration % 3600) // 60)
|
||||
)
|
||||
else:
|
||||
dur_str = "{} minutes".format(int((duration % 3600) // 60))
|
||||
|
||||
if not lion.session.data.tag:
|
||||
await ctx.embed_reply(
|
||||
f"{target.mention} has been working in <#{lion.session.data.channelid}> for **{dur_str}**!"
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
f"{target.mention} has been working on **{lion.session.data.tag}**"
|
||||
f" in <#{lion.session.data.channelid}> for **{dur_str}**!"
|
||||
)
|
||||
else:
|
||||
# Assume setting tag
|
||||
tag = ctx.args
|
||||
|
||||
if not (session := ctx.alion.session):
|
||||
return await ctx.error_reply(
|
||||
"You aren't working right now! Join a study channel and try again!"
|
||||
)
|
||||
|
||||
if len(tag) > MAX_TAG_LENGTH:
|
||||
return await ctx.error_reply(
|
||||
f"Please keep your tag under `{MAX_TAG_LENGTH}` characters long!"
|
||||
)
|
||||
|
||||
old_tag = session.data.tag
|
||||
session.data.tag = tag
|
||||
if old_tag:
|
||||
await ctx.embed_reply(
|
||||
f"You have updated your session study tag. Good luck with **{tag}**!"
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"You have set your session study tag!\nIt will be reset when you leave, or join another channel.\n"
|
||||
f"Good luck with **{tag}**!"
|
||||
)
|
||||
else:
|
||||
# View current session, stats, and guide.
|
||||
if (session := ctx.alion.session):
|
||||
duration = session.duration
|
||||
if duration > 3600:
|
||||
dur_str = "{}h {}m".format(
|
||||
int(duration // 3600),
|
||||
int((duration % 3600) // 60)
|
||||
)
|
||||
else:
|
||||
dur_str = "{} minutes".format(int((duration % 3600) / 60))
|
||||
if not session.data.tag:
|
||||
await ctx.embed_reply(
|
||||
f"You have been working in <#{session.data.channelid}> for **{dur_str}**!\n"
|
||||
f"Describe what you are working on with "
|
||||
f"`{ctx.best_prefix}now <tag>`, e.g. `{ctx.best_prefix}now Maths`"
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
f"You have been working on **{session.data.tag}**"
|
||||
f" in <#{session.data.channelid}> for **{dur_str}**!"
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths`"
|
||||
)
|
||||
|
||||
# TODO: Favourite tags listing
|
||||
# Get tag history ranking top 5
|
||||
# If there are any, display top 5
|
||||
# Otherwise do nothing
|
||||
...
|
||||
86
bot/modules/pending-rewrite/study/tracking/data.py
Normal file
86
bot/modules/pending-rewrite/study/tracking/data.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
from data import Table, RowTable, tables
|
||||
from utils.lib import FieldEnum
|
||||
|
||||
|
||||
untracked_channels = Table('untracked_channels')
|
||||
|
||||
|
||||
class SessionChannelType(FieldEnum):
|
||||
"""
|
||||
The possible session channel types.
|
||||
"""
|
||||
# NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name
|
||||
STANDARD = 'STANDARD', "Standard"
|
||||
ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room"
|
||||
RENTED = 'RENTED', "Private Room"
|
||||
EXTERNAL = 'EXTERNAL', "Unknown"
|
||||
|
||||
|
||||
session_history = Table('session_history')
|
||||
current_sessions = RowTable(
|
||||
'current_sessions',
|
||||
('guildid', 'userid', 'channelid', 'channel_type',
|
||||
'rating', 'tag',
|
||||
'start_time',
|
||||
'live_duration', 'live_start',
|
||||
'stream_duration', 'stream_start',
|
||||
'video_duration', 'video_start',
|
||||
'hourly_coins', 'hourly_live_coins'),
|
||||
('guildid', 'userid'),
|
||||
cache={} # Keep all current sessions in cache
|
||||
)
|
||||
|
||||
|
||||
@current_sessions.save_query
|
||||
def close_study_session(guildid, userid):
|
||||
"""
|
||||
Close a member's current session if it exists and update the member cache.
|
||||
"""
|
||||
# Execute the `close_study_session` database function
|
||||
with current_sessions.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.callproc('close_study_session', (guildid, userid))
|
||||
rows = cursor.fetchall()
|
||||
# The row has been deleted, remove the from current sessions cache
|
||||
current_sessions.row_cache.pop((guildid, userid), None)
|
||||
# Use the function output to update the member cache
|
||||
tables.lions._make_rows(*rows)
|
||||
|
||||
|
||||
@session_history.save_query
|
||||
def study_time_since(guildid, userid, timestamp):
|
||||
"""
|
||||
Retrieve the total member study time (in seconds) since the given timestamp.
|
||||
Includes the current session, if it exists.
|
||||
"""
|
||||
with session_history.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.callproc('study_time_since', (guildid, userid, timestamp))
|
||||
rows = cursor.fetchall()
|
||||
return (rows[0][0] if rows else None) or 0
|
||||
|
||||
|
||||
@session_history.save_query
|
||||
def study_times_since(guildid, userid, *timestamps):
|
||||
"""
|
||||
Retrieve the total member study time (in seconds) since the given timestamps.
|
||||
Includes the current session, if it exists.
|
||||
"""
|
||||
with session_history.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
data = execute_values(
|
||||
cursor,
|
||||
"""
|
||||
SELECT study_time_since(t.guildid, t.userid, t.timestamp)
|
||||
FROM (VALUES %s)
|
||||
AS t (guildid, userid, timestamp)
|
||||
""",
|
||||
[(guildid, userid, timestamp) for timestamp in timestamps],
|
||||
fetch=True
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
members_totals = Table('members_totals')
|
||||
504
bot/modules/pending-rewrite/study/tracking/session_tracker.py
Normal file
504
bot/modules/pending-rewrite/study/tracking/session_tracker.py
Normal file
@@ -0,0 +1,504 @@
|
||||
import asyncio
|
||||
import discord
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Dict
|
||||
from collections import defaultdict
|
||||
|
||||
from utils.lib import utc_now
|
||||
from data import tables
|
||||
from data.conditions import THIS_SHARD
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from ..module import module
|
||||
from .data import current_sessions, SessionChannelType
|
||||
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
|
||||
|
||||
|
||||
class Session:
|
||||
"""
|
||||
A `Session` describes an ongoing study session by a single guild member.
|
||||
A member is counted as studying when they are in a tracked voice channel.
|
||||
|
||||
This class acts as an opaque interface to the corresponding `sessions` data row.
|
||||
"""
|
||||
__slots__ = (
|
||||
'guildid',
|
||||
'userid',
|
||||
'_expiry_task'
|
||||
)
|
||||
# Global cache of ongoing sessions
|
||||
sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict)
|
||||
|
||||
# Global cache of members pending session start (waiting for daily cap reset)
|
||||
members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict)
|
||||
|
||||
def __init__(self, guildid, userid):
|
||||
self.guildid = guildid
|
||||
self.userid = userid
|
||||
|
||||
self._expiry_task: asyncio.Task = None
|
||||
|
||||
@classmethod
|
||||
def get(cls, guildid, userid):
|
||||
"""
|
||||
Fetch the current session for the provided member.
|
||||
If there is no current session, returns `None`.
|
||||
"""
|
||||
return cls.sessions[guildid].get(userid, None)
|
||||
|
||||
@classmethod
|
||||
def start(cls, member: discord.Member, state: discord.VoiceState):
|
||||
"""
|
||||
Start a new study session for the provided member.
|
||||
"""
|
||||
guildid = member.guild.id
|
||||
userid = member.id
|
||||
now = utc_now()
|
||||
|
||||
if userid in cls.sessions[guildid]:
|
||||
raise ValueError("A session for this member already exists!")
|
||||
|
||||
# If the user is study capped, schedule the session start for the next day
|
||||
if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 10:
|
||||
if pending := cls.members_pending[guildid].pop(userid, None):
|
||||
pending.cancel()
|
||||
task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state))
|
||||
cls.members_pending[guildid][userid] = task
|
||||
client.log(
|
||||
"Member (uid:{}) in (gid:{}) is study capped, "
|
||||
"delaying session start for {} seconds until start of next day.".format(
|
||||
userid, guildid, lion.remaining_in_day
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: More reliable channel type determination
|
||||
if state.channel.id in tables.rented.row_cache:
|
||||
channel_type = SessionChannelType.RENTED
|
||||
elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data:
|
||||
channel_type = SessionChannelType.ACCOUNTABILITY
|
||||
else:
|
||||
channel_type = SessionChannelType.STANDARD
|
||||
|
||||
current_sessions.create_row(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
channelid=state.channel.id,
|
||||
channel_type=channel_type,
|
||||
start_time=now,
|
||||
live_start=now if (state.self_video or state.self_stream) else None,
|
||||
stream_start=now if state.self_stream else None,
|
||||
video_start=now if state.self_video else None,
|
||||
hourly_coins=hourly_reward.get(guildid).value,
|
||||
hourly_live_coins=hourly_live_bonus.get(guildid).value
|
||||
)
|
||||
session = cls(guildid, userid).activate()
|
||||
client.log(
|
||||
"Started session: {}".format(session.data),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.DEBUG,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _delayed_start(cls, guildid, userid, *args):
|
||||
delay = Lion.fetch(guildid, userid).remaining_in_day
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
cls.start(*args)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""
|
||||
RowTable Session identification key.
|
||||
"""
|
||||
return (self.guildid, self.userid)
|
||||
|
||||
@property
|
||||
def lion(self):
|
||||
"""
|
||||
The Lion member object associated with this member.
|
||||
"""
|
||||
return Lion.fetch(self.guildid, self.userid)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""
|
||||
Row of the `current_sessions` table corresponding to this session.
|
||||
"""
|
||||
return current_sessions.fetch(self.key)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""
|
||||
Current duration of the session.
|
||||
"""
|
||||
return (utc_now() - self.data.start_time).total_seconds()
|
||||
|
||||
@property
|
||||
def coins_earned(self):
|
||||
"""
|
||||
Number of coins earned so far.
|
||||
"""
|
||||
data = self.data
|
||||
|
||||
coins = self.duration * data.hourly_coins
|
||||
coins += data.live_duration * data.hourly_live_coins
|
||||
if data.live_start:
|
||||
coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins
|
||||
return coins // 3600
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Activate the study session.
|
||||
This adds the session to the studying members cache,
|
||||
and schedules the session expiry, based on the daily study cap.
|
||||
"""
|
||||
# Add to the active cache
|
||||
self.sessions[self.guildid][self.userid] = self
|
||||
|
||||
# Schedule the session expiry
|
||||
self.schedule_expiry()
|
||||
|
||||
# Return self for easy chaining
|
||||
return self
|
||||
|
||||
def schedule_expiry(self):
|
||||
"""
|
||||
Schedule session termination when the user reaches the maximum daily study time.
|
||||
"""
|
||||
asyncio.create_task(self._schedule_expiry())
|
||||
|
||||
async def _schedule_expiry(self):
|
||||
# Cancel any existing expiry
|
||||
if self._expiry_task and not self._expiry_task.done():
|
||||
self._expiry_task.cancel()
|
||||
|
||||
# Wait for the maximum session length
|
||||
self._expiry_task = asyncio.create_task(asyncio.sleep(self.lion.remaining_study_today))
|
||||
try:
|
||||
await self._expiry_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
if self.lion.remaining_study_today <= 10:
|
||||
# End the session
|
||||
# Note that the user will not automatically start a new session when the day starts
|
||||
# TODO: Notify user? Disconnect them?
|
||||
client.log(
|
||||
"Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format(
|
||||
self.userid, self.guildid, self.data
|
||||
),
|
||||
context="SESSION_TRACKER"
|
||||
)
|
||||
self.finish()
|
||||
else:
|
||||
# It's possible the expiry time was pushed forwards while waiting
|
||||
# If so, reschedule
|
||||
self.schedule_expiry()
|
||||
|
||||
def finish(self):
|
||||
"""
|
||||
Close the study session.
|
||||
"""
|
||||
# Note that save_live_status doesn't need to be called here
|
||||
# The database saving procedure will account for the values.
|
||||
current_sessions.queries.close_study_session(*self.key)
|
||||
|
||||
# Remove session from active cache
|
||||
self.sessions[self.guildid].pop(self.userid, None)
|
||||
|
||||
# Cancel any existing expiry task
|
||||
if self._expiry_task and not self._expiry_task.done():
|
||||
self._expiry_task.cancel()
|
||||
|
||||
def save_live_status(self, state: discord.VoiceState):
|
||||
"""
|
||||
Update the saved live status of the member.
|
||||
"""
|
||||
has_video = state.self_video
|
||||
has_stream = state.self_stream
|
||||
is_live = has_video or has_stream
|
||||
|
||||
now = utc_now()
|
||||
data = self.data
|
||||
|
||||
with data.batch_update():
|
||||
# Update video session stats
|
||||
if data.video_start:
|
||||
data.video_duration += (now - data.video_start).total_seconds()
|
||||
data.video_start = now if has_video else None
|
||||
|
||||
# Update stream session stats
|
||||
if data.stream_start:
|
||||
data.stream_duration += (now - data.stream_start).total_seconds()
|
||||
data.stream_start = now if has_stream else None
|
||||
|
||||
# Update overall live session stats
|
||||
if data.live_start:
|
||||
data.live_duration += (now - data.live_start).total_seconds()
|
||||
data.live_start = now if is_live else None
|
||||
|
||||
|
||||
async def session_voice_tracker(client, member, before, after):
|
||||
"""
|
||||
Voice update event dispatcher for study session tracking.
|
||||
"""
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
Lion.fetch(guild.id, member.id).update_saved_data(member)
|
||||
session = Session.get(guild.id, member.id)
|
||||
|
||||
if before.channel == after.channel:
|
||||
# Voice state change without moving channel
|
||||
if session and ((before.self_video != after.self_video) or (before.self_stream != after.self_stream)):
|
||||
# Live status has changed!
|
||||
session.save_live_status(after)
|
||||
else:
|
||||
# Member changed channel
|
||||
# End the current session and start a new one, if applicable
|
||||
if session:
|
||||
if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id):
|
||||
client.log(
|
||||
"The previous voice state for "
|
||||
"member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) "
|
||||
"does not match their current study session!\n"
|
||||
"Session channel is (cid:{scid}), but the previous channel is {previous}.".format(
|
||||
member=member,
|
||||
guild=member.guild,
|
||||
scid=scid,
|
||||
previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None"
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
client.log(
|
||||
"Ending study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format(
|
||||
member=member,
|
||||
session=session.data
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
# End the current session
|
||||
session.finish()
|
||||
elif pending := Session.members_pending[guild.id].pop(member.id, None):
|
||||
client.log(
|
||||
"Cancelling pending study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format(
|
||||
member=member
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
pending.cancel()
|
||||
|
||||
if after.channel:
|
||||
blacklist = client.user_blacklist()
|
||||
guild_blacklist = client.objects['ignored_members'][guild.id]
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
start_session = (
|
||||
(after.channel.id not in untracked)
|
||||
and (member.id not in blacklist)
|
||||
and (member.id not in guild_blacklist)
|
||||
)
|
||||
if start_session:
|
||||
# Start a new session for the member
|
||||
client.log(
|
||||
"Starting a new voice channel study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.name} (gid:{member.guild.id}).".format(
|
||||
member=member,
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
session = Session.start(member, after)
|
||||
|
||||
|
||||
async def leave_guild_sessions(client, guild):
|
||||
"""
|
||||
`guild_leave` hook.
|
||||
Close all sessions in the guild when we leave.
|
||||
"""
|
||||
sessions = list(Session.sessions[guild.id].values())
|
||||
for session in sessions:
|
||||
session.finish()
|
||||
client.log(
|
||||
"Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)),
|
||||
context="SESSION_TRACKER"
|
||||
)
|
||||
|
||||
|
||||
async def join_guild_sessions(client, guild):
|
||||
"""
|
||||
`guild_join` hook.
|
||||
Refresh all sessions for the guild when we rejoin.
|
||||
"""
|
||||
# Delete existing current sessions, which should have been closed when we left
|
||||
# It is possible we were removed from the guild during an outage
|
||||
current_sessions.delete_where(guildid=guild.id)
|
||||
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
members = [
|
||||
member
|
||||
for channel in guild.voice_channels
|
||||
for member in channel.members
|
||||
if channel.members and channel.id not in untracked and not member.bot
|
||||
]
|
||||
for member in members:
|
||||
client.log(
|
||||
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.voice.channel.name,
|
||||
member.voice.channel.id,
|
||||
member.guild.name,
|
||||
member.guild.id
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.INFO,
|
||||
post=False
|
||||
)
|
||||
Session.start(member, member.voice)
|
||||
|
||||
# Log newly started sessions
|
||||
client.log(
|
||||
"Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format(
|
||||
guild.name,
|
||||
guild.id,
|
||||
len(members)
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
)
|
||||
|
||||
|
||||
async def _init_session_tracker(client):
|
||||
"""
|
||||
Load ongoing saved study sessions into the session cache,
|
||||
update them depending on the current voice states,
|
||||
and attach the voice event handler.
|
||||
"""
|
||||
# Ensure the client caches are ready and guilds are chunked
|
||||
await client.wait_until_ready()
|
||||
|
||||
# Pre-cache the untracked channels
|
||||
await untracked_channels.launch_task(client)
|
||||
|
||||
# Log init start and define logging counters
|
||||
client.log(
|
||||
"Loading ongoing study sessions.",
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
resumed = 0
|
||||
ended = 0
|
||||
|
||||
# Grab all ongoing sessions from data
|
||||
rows = current_sessions.fetch_rows_where(guildid=THIS_SHARD)
|
||||
|
||||
# Iterate through, resume or end as needed
|
||||
for row in rows:
|
||||
if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None:
|
||||
try:
|
||||
# Load the Session
|
||||
session = Session(row.guildid, row.userid)
|
||||
|
||||
# Find the channel and member voice state
|
||||
voice = None
|
||||
if channel := guild.get_channel(row.channelid):
|
||||
voice = next((member.voice for member in channel.members if member.id == row.userid), None)
|
||||
|
||||
# Resume or end as required
|
||||
if voice and voice.channel:
|
||||
client.log(
|
||||
"Resuming ongoing session: {}".format(row),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
session.activate()
|
||||
session.save_live_status(voice)
|
||||
resumed += 1
|
||||
else:
|
||||
client.log(
|
||||
"Ending already completed session: {}".format(row),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
session.finish()
|
||||
ended += 1
|
||||
except Exception:
|
||||
# Fatal error
|
||||
client.log(
|
||||
"Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()),
|
||||
context="SESSION_INIT",
|
||||
level=logging.CRITICAL
|
||||
)
|
||||
module.ready = False
|
||||
return
|
||||
|
||||
# Log resumed sessions
|
||||
client.log(
|
||||
"Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended),
|
||||
context="SESSION_INIT",
|
||||
level=logging.INFO
|
||||
)
|
||||
|
||||
# Now iterate through members of all tracked voice channels
|
||||
# Start sessions if they don't already exist
|
||||
tracked_channels = [
|
||||
channel
|
||||
for guild in client.guilds
|
||||
for channel in guild.voice_channels
|
||||
if channel.members and channel.id not in untracked_channels.get(guild.id).data
|
||||
]
|
||||
new_members = [
|
||||
member
|
||||
for channel in tracked_channels
|
||||
for member in channel.members
|
||||
if not member.bot and not Session.get(member.guild.id, member.id)
|
||||
]
|
||||
for member in new_members:
|
||||
client.log(
|
||||
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.voice.channel.name,
|
||||
member.voice.channel.id,
|
||||
member.guild.name,
|
||||
member.guild.id
|
||||
),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
Session.start(member, member.voice)
|
||||
|
||||
# Log newly started sessions
|
||||
client.log(
|
||||
"Started {} new study sessions from current voice channel members.".format(len(new_members)),
|
||||
context="SESSION_INIT",
|
||||
level=logging.INFO
|
||||
)
|
||||
|
||||
# Now that we are in a valid initial state, attach the session event handler
|
||||
client.add_after_event("voice_state_update", session_voice_tracker)
|
||||
client.add_after_event("guild_remove", leave_guild_sessions)
|
||||
client.add_after_event("guild_join", join_guild_sessions)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_session_tracker(client):
|
||||
"""
|
||||
Launch the study session initialiser.
|
||||
Doesn't block on the client being ready.
|
||||
"""
|
||||
client.objects['sessions'] = Session.sessions
|
||||
asyncio.create_task(_init_session_tracker(client))
|
||||
143
bot/modules/pending-rewrite/study/tracking/settings.py
Normal file
143
bot/modules/pending-rewrite/study/tracking/settings.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import settings
|
||||
from settings import GuildSettings
|
||||
from wards import guild_admin
|
||||
|
||||
from .data import untracked_channels
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class untracked_channels(settings.ChannelList, settings.ListData, settings.Setting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = 'untracked_channels'
|
||||
|
||||
_table_interface = untracked_channels
|
||||
_setting = settings.VoiceChannel
|
||||
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'channelid'
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "untracked_channels"
|
||||
desc = "Channels to ignore for study time tracking."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"Time spent in these voice channels won't add study time or lioncoins to the member."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The untracked channels have been updated:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "Study time will now be counted in all channels."
|
||||
|
||||
@classmethod
|
||||
async def launch_task(cls, client):
|
||||
"""
|
||||
Launch initialisation step for the `untracked_channels` setting.
|
||||
|
||||
Pre-fill cache for the guilds with currently active voice channels.
|
||||
"""
|
||||
active_guildids = [
|
||||
guild.id
|
||||
for guild in client.guilds
|
||||
if any(channel.members for channel in guild.voice_channels)
|
||||
]
|
||||
if active_guildids:
|
||||
cache = {guildid: [] for guildid in active_guildids}
|
||||
rows = cls._table_interface.select_where(
|
||||
guildid=active_guildids
|
||||
)
|
||||
for row in rows:
|
||||
cache[row['guildid']].append(row['channelid'])
|
||||
cls._cache.update(cache)
|
||||
client.log(
|
||||
"Cached {} untracked channels for {} active guilds.".format(
|
||||
len(rows),
|
||||
len(cache)
|
||||
),
|
||||
context="UNTRACKED_CHANNELS"
|
||||
)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class hourly_reward(settings.Integer, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "hourly_reward"
|
||||
_data_column = "study_hourly_reward"
|
||||
|
||||
display_name = "hourly_reward"
|
||||
desc = "Number of LionCoins given per hour of study."
|
||||
|
||||
_default = 50
|
||||
_max = 32767
|
||||
|
||||
long_desc = (
|
||||
"Each spent in a voice channel will reward this number of LionCoins."
|
||||
)
|
||||
_accepts = "An integer number of LionCoins to reward."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be rewarded `{}` LionCoins per hour of study.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class hourly_live_bonus(settings.Integer, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "hourly_live_bonus"
|
||||
_data_column = "study_hourly_live_bonus"
|
||||
|
||||
display_name = "hourly_live_bonus"
|
||||
desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)."
|
||||
|
||||
_default = 150
|
||||
_max = 32767
|
||||
|
||||
long_desc = (
|
||||
"LionCoin bonus earnt for every hour a member streams in a voice channel, including video. "
|
||||
"This is in addition to the standard `hourly_reward`."
|
||||
)
|
||||
_accepts = "An integer number of LionCoins to reward."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class daily_study_cap(settings.Duration, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "daily_study_cap"
|
||||
_data_column = "daily_study_cap"
|
||||
|
||||
display_name = "daily_study_cap"
|
||||
desc = "Maximum amount of recorded study time per member per day."
|
||||
|
||||
_default = 16 * 60 * 60
|
||||
_default_multiplier = 60 * 60
|
||||
|
||||
_max = 25 * 60 * 60
|
||||
|
||||
long_desc = (
|
||||
"The maximum amount of study time that can be recorded for a member per day, "
|
||||
"intended to remove system encouragement for unhealthy or obsessive behaviour.\n"
|
||||
"The member may study for longer, but their sessions will not be tracked. "
|
||||
"The start and end of the day are determined by the member's configured timezone."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
# Refresh expiry for all sessions in the guild
|
||||
[session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()]
|
||||
|
||||
return "The maximum tracked daily study time is now {}.".format(self.formatted)
|
||||
109
bot/modules/pending-rewrite/study/tracking/time_tracker.py
Normal file
109
bot/modules/pending-rewrite/study/tracking/time_tracker.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import itertools
|
||||
import traceback
|
||||
import logging
|
||||
import asyncio
|
||||
from time import time
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
|
||||
from ..module import module
|
||||
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
|
||||
|
||||
|
||||
last_scan = {} # guildid -> timestamp
|
||||
|
||||
|
||||
def _scan(guild):
|
||||
"""
|
||||
Scan the tracked voice channels and add time and coins to each user.
|
||||
"""
|
||||
# Current timestamp
|
||||
now = time()
|
||||
|
||||
# Get last scan timestamp
|
||||
try:
|
||||
last = last_scan[guild.id]
|
||||
except KeyError:
|
||||
return
|
||||
finally:
|
||||
last_scan[guild.id] = now
|
||||
|
||||
# Calculate time since last scan
|
||||
interval = now - last
|
||||
|
||||
# Discard if it has been more than 20 minutes (discord outage?)
|
||||
if interval > 60 * 20:
|
||||
return
|
||||
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
guild_hourly_reward = hourly_reward.get(guild.id).data
|
||||
guild_hourly_live_bonus = hourly_live_bonus.get(guild.id).data
|
||||
|
||||
channel_members = (
|
||||
channel.members for channel in guild.voice_channels if channel.id not in untracked
|
||||
)
|
||||
|
||||
members = itertools.chain(*channel_members)
|
||||
# TODO filter out blacklisted users
|
||||
|
||||
blacklist = client.user_blacklist()
|
||||
guild_blacklist = client.objects['ignored_members'][guild.id]
|
||||
|
||||
for member in members:
|
||||
if member.bot:
|
||||
continue
|
||||
if member.id in blacklist or member.id in guild_blacklist:
|
||||
continue
|
||||
lion = Lion.fetch(guild.id, member.id)
|
||||
|
||||
# Add time
|
||||
lion.addTime(interval, flush=False)
|
||||
|
||||
# Add coins
|
||||
hour_reward = guild_hourly_reward
|
||||
if member.voice.self_stream or member.voice.self_video:
|
||||
hour_reward += guild_hourly_live_bonus
|
||||
|
||||
lion.addCoins(hour_reward * interval / (3600), flush=False, bonus=True)
|
||||
|
||||
|
||||
async def _study_tracker():
|
||||
"""
|
||||
Scanner launch loop.
|
||||
"""
|
||||
while True:
|
||||
while not client.is_ready():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Launch scanners on each guild
|
||||
for guild in client.guilds:
|
||||
# Short wait to pass control to other asyncio tasks if they need it
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
# Scan the guild
|
||||
_scan(guild)
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while scanning guild '{}'(gid:{})! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
guild.name,
|
||||
guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="VOICE_ACTIVITY_SCANNER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_study_tracker(client):
|
||||
# First pre-load the untracked channels
|
||||
await untracked_channels.launch_task(client)
|
||||
asyncio.create_task(_study_tracker())
|
||||
|
||||
|
||||
# TODO: Logout handler, sync
|
||||
Reference in New Issue
Block a user