Add module.

This commit is contained in:
2021-09-12 11:37:07 +03:00
parent 70fa80fe29
commit a316775cad
7 changed files with 1087 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
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."
@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
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 = 10
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)

View File

@@ -0,0 +1,344 @@
import datetime
import traceback
import logging
import asyncio
import contextlib
import discord
from meta import client
from data.conditions import GEQ
from core import Lion
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:
update_rows = new_study_badges.select_where(
_timestamp=GEQ(client.appdata.last_study_badge_scan or 0)
)
else:
update_rows = new_study_badges.select_where()
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')
)
# Update the app scan time
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
async def _update_guild_badges(guild, member_rows):
"""
Notify, update, and log role changes for a single guild.
Expects a valid `guild` and a list of Rows of `new_study_badges`.
"""
# TODO: Locking
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))
)
# 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 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` Study role doesn't exist!"
}
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):
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 new_row and (old_row is None or new_row.required_time > old_row.required_time):
embed = discord.Embed(
title="New Study Badge!",
description="Congratulations! You have earned {}!".format(
"**{}**".format(to_add.name) if to_add else "a new study badge!"
),
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.
"""
Lion.fetch(member.guild.id, member.id).flush()
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)
@client.add_after_event("voice_state_update")
async def voice_studybadge_updater(client, member, before, after):
if not client.is_ready():
# The poll loop will pick it up
return
if before.channel and not after.channel:
await _update_member_studybadge(member)
@module.launch_task
async def launch_study_badge_tracker(client):
asyncio.create_task(study_badge_tracker())

26
bot/modules/study/data.py Normal file
View File

@@ -0,0 +1,26 @@
from cachetools import cached
from data import Table, RowTable
untracked_channels = Table('untracked_channels')
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,86 @@
import datetime
import discord
from cmdClient.checks import in_guild
from utils.lib import strfdur
from data import tables
from core import Lion
from .module import module
@module.cmd(
"stats",
desc="View your study statistics!"
)
@in_guild()
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <user mention>
Description:
View the study statistics for yourself or the mentioned user.
"""
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their statistics!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# Collect the required target data
lion = Lion.fetch(ctx.guild.id, target.id)
rank_data = tables.lions.select_one_where(
userid=target.id,
guildid=ctx.guild.id,
select_columns=(
"row_number() OVER (PARTITION BY guildid ORDER BY tracked_time DESC, userid ASC) AS time_rank",
"row_number() OVER (PARTITION BY guildid ORDER BY coins DESC, userid ASC) AS coin_rank",
)
)
# Extract and format data
time = strfdur(lion.time)
coins = lion.coins
workouts = lion.data.workout_count
if lion.data.last_study_badgeid:
badge_row = tables.study_badges.fetch(lion.data.last_study_badgeid)
league = "<@&{}>".format(badge_row.roleid)
else:
league = "No league yet!"
time_lb_pos = rank_data['time_rank']
coin_lb_pos = rank_data['coin_rank']
# Build embed
embed = discord.Embed(
colour=discord.Colour.blue(),
timestamp=datetime.datetime.utcnow(),
title="Revision Statistics"
).set_footer(text=str(target), icon_url=target.avatar_url).set_thumbnail(url=target.avatar_url)
embed.add_field(
name="📚 Study Time",
value=time
)
embed.add_field(
name="🦁 Revision League",
value=league
)
embed.add_field(
name="🦁 LionCoins",
value=coins
)
embed.add_field(
name="🏆 Leaderboard Position",
value="Time: {}\n LC: {}".format(time_lb_pos, coin_lb_pos)
)
embed.add_field(
name="💪 Workouts",
value=workouts
)
embed.add_field(
name="📋 Attendence",
value="TBD"
)
await ctx.reply(embed=embed)

View File

@@ -0,0 +1,333 @@
import re
import asyncio
import discord
from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation
from utils.lib import parse_dur, strfdur, parse_ranges
from wards import is_guild_admin
from core.data import lions
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 --clear
{prefix}studybadges --refresh
Description:
View or modify the study badges in this guild.
*Modification requires administrator permissions.*
"""
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']:
await ensure_admin(ctx)
if not await ctx.input("Are you sure you want to delete **all** study badges in this server?"):
return
study_badges.delete_where(guildid=ctx.guild.id)
await ctx.reply("All study badges have been removed.")
# TODO: Offer to delete roles
elif flags['remove']:
await ensure_admin(ctx)
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
if ctx.args:
# TODO: Handle role input
...
else:
# TODO: Interactive multi-selector
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.")
try:
await out_msg.delete()
await message.delete()
except discord.HTTPException:
pass
if message.content.lower() == 'c':
return
rows = [guild_roles[index-1] for index in parse_ranges(message.content) if index <= len(guild_roles)]
if rows:
study_badges.delete_where(badgeid=[row.badgeid for row in rows])
else:
return await ctx.error_reply("Nothing to delete!")
if len(rows) == len(guild_roles):
await ctx.reply("All study badges deleted.")
else:
await show_badge_list(
ctx,
desc="`{}` badge{} removed.".format(len(rows), 's' if len(rows) > 1 else '')
)
# TODO: Offer to delete roles
# TODO: Offer to refresh
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]
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.input(
"`{}` 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 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 `Cub 200h`."
# )
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`."
)
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,100 @@
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 . import admin
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
# Calculuate time since last scan
interval = now - last
# Discard if it has been more than 20 minutes (discord outage?)
if interval > 60 * 20:
return
untracked = admin.untracked_channels.get(guild.id).data
hourly_reward = admin.hourly_reward.get(guild.id).data
hourly_live_bonus = admin.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
for member in members:
lion = Lion.fetch(guild.id, member.id)
# Add time
lion.addTime(interval, flush=False)
# Add coins
hour_reward = hourly_reward
if member.voice.self_stream or member.voice.self_video:
hour_reward += hourly_live_bonus
lion.addCoins(hour_reward * interval / (3600), flush=False)
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):
asyncio.create_task(_study_tracker())
# TODO: Logout handler, sync

View File

@@ -0,0 +1,115 @@
from cmdClient.checks import in_guild
import data
from core import Lion
from data import tables
from utils import interactive # noqa
from .module import module
first_emoji = "🥇"
second_emoji = "🥈"
third_emoji = "🥉"
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100'),
help_aliases={'top100': "View the Study Time top 100."}
)
@in_guild()
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
Use the paging reactions or send `p<n>` to switch pages (e.g. `p11` to switch to page 11).
"""
# Handle args
if ctx.args and not ctx.args == "100":
return await ctx.error_reply(
"**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix)
)
top100 = (ctx.args == "100" or ctx.alias == "top100")
# Flush any pending coin transactions
Lion.sync()
# Fetch the leaderboard
user_data = tables.lions.select_where(
guildid=ctx.guild.id,
userid=data.NOT([m.id for m in ctx.guild_settings.unranked_roles.members]),
select_columns=('userid', 'tracked_time'),
_extra="ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "")
)
# Quit early if the leaderboard is empty
if not user_data:
return await ctx.reply("No leaderboard entries yet!")
# Extract entries
author_index = None
entries = []
for i, (userid, time) in enumerate(user_data):
member = ctx.guild.get_member(userid)
name = member.display_name if member else str(userid)
name = name.replace('*', ' ').replace('_', ' ')
num_str = "{}.".format(i+1)
hours = time // 3600
minutes = time // 60 % 60
seconds = time % 60
time_str = "{}:{:02}:{:02}".format(
hours,
minutes,
seconds
)
if ctx.author.id == userid:
author_index = i
entries.append((num_str, name, time_str))
# Extract blocks
blocks = [entries[i:i+20] for i in range(0, len(entries), 20)]
block_count = len(blocks)
# Build strings
header = "Study Time Top 100" if top100 else "Study Time Leaderboard"
if block_count > 1:
header += " (Page {{page}}/{})".format(block_count)
# Build pages
pages = []
for i, block in enumerate(blocks):
max_num_l, max_name_l, max_time_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)]
body = '\n'.join(
"{:>{}} {:<{}} \t {:>{}} {} {}".format(
entry[0], max_num_l,
entry[1], max_name_l + 2,
entry[2], max_time_l + 1,
first_emoji if i == 0 and j == 0 else (
second_emoji if i == 0 and j == 1 else (
third_emoji if i == 0 and j == 2 else ''
)
),
"" if author_index is not None and author_index == i * 20 + j else ""
)
for j, entry in enumerate(block)
)
title = header.format(page=i+1)
line = '='*len(title)
pages.append(
"```md\n{}\n{}\n{}```".format(title, line, body)
)
# Finally, page the results
await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0)