rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -0,0 +1,5 @@
from .module import module
from . import badges
from . import timers
from . import tracking

View File

@@ -0,0 +1,2 @@
from . import badge_tracker
from . import studybadge_cmd

View 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())

View 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)

View 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)

View File

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

View 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()

View File

@@ -0,0 +1,3 @@
from .Timer import Timer
from . import commands
from . import settings

View 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."
)

View 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={}
)

View 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

View File

@@ -0,0 +1,4 @@
from . import data
from . import settings
from . import session_tracker
from . import commands

View 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
...

View 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')

View 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))

View 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)

View 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