diff --git a/bot/modules/study/admin.py b/bot/modules/study/admin.py new file mode 100644 index 00000000..6a1a9b6b --- /dev/null +++ b/bot/modules/study/admin.py @@ -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) diff --git a/bot/modules/study/badge_tracker.py b/bot/modules/study/badge_tracker.py new file mode 100644 index 00000000..80e4e454 --- /dev/null +++ b/bot/modules/study/badge_tracker.py @@ -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()) diff --git a/bot/modules/study/data.py b/bot/modules/study/data.py new file mode 100644 index 00000000..005765bb --- /dev/null +++ b/bot/modules/study/data.py @@ -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) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py new file mode 100644 index 00000000..bebeb024 --- /dev/null +++ b/bot/modules/study/stats_cmd.py @@ -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 + 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) diff --git a/bot/modules/study/studybadge_cmd.py b/bot/modules/study/studybadge_cmd.py new file mode 100644 index 00000000..e9d240c7 --- /dev/null +++ b/bot/modules/study/studybadge_cmd.py @@ -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] , + {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:** ` `, 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:** `, `, 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) diff --git a/bot/modules/study/time_tracker.py b/bot/modules/study/time_tracker.py new file mode 100644 index 00000000..67085a0f --- /dev/null +++ b/bot/modules/study/time_tracker.py @@ -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 diff --git a/bot/modules/study/top_cmd.py b/bot/modules/study/top_cmd.py new file mode 100644 index 00000000..6fc138e2 --- /dev/null +++ b/bot/modules/study/top_cmd.py @@ -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` 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)