Add module.
This commit is contained in:
83
bot/modules/study/admin.py
Normal file
83
bot/modules/study/admin.py
Normal 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)
|
||||||
344
bot/modules/study/badge_tracker.py
Normal file
344
bot/modules/study/badge_tracker.py
Normal 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
26
bot/modules/study/data.py
Normal 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)
|
||||||
86
bot/modules/study/stats_cmd.py
Normal file
86
bot/modules/study/stats_cmd.py
Normal 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)
|
||||||
333
bot/modules/study/studybadge_cmd.py
Normal file
333
bot/modules/study/studybadge_cmd.py
Normal 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)
|
||||||
100
bot/modules/study/time_tracker.py
Normal file
100
bot/modules/study/time_tracker.py
Normal 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
|
||||||
115
bot/modules/study/top_cmd.py
Normal file
115
bot/modules/study/top_cmd.py
Normal 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)
|
||||||
Reference in New Issue
Block a user