From 10f048fabca05a3348ed597bdc72d691b34aaaa1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 03:16:56 +0200 Subject: [PATCH 01/56] fix (rooms): Avoid loading rooms in dead guilds. --- bot/modules/accountability/TimeSlot.py | 16 ++++++++++------ bot/modules/accountability/tracker.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index ac12efea..2a87826a 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -218,6 +218,9 @@ class TimeSlot: """ Load data and update applicable caches. """ + if not self.guild: + return self + # Load setting data self.category = GuildSettings(self.guild.id).accountability_category.value self.lobby = GuildSettings(self.guild.id).accountability_lobby.value @@ -389,13 +392,14 @@ class TimeSlot: pass # Reward members appropriately - guild_settings = GuildSettings(self.guild.id) - reward = guild_settings.accountability_reward.value - if all(mem.has_attended for mem in self.members.values()): - reward += guild_settings.accountability_bonus.value + if self.guild: + guild_settings = GuildSettings(self.guild.id) + reward = guild_settings.accountability_reward.value + if all(mem.has_attended for mem in self.members.values()): + reward += guild_settings.accountability_bonus.value - for memid in self.members: - Lion.fetch(self.guild.id, memid).addCoins(reward) + for memid in self.members: + Lion.fetch(self.guild.id, memid).addCoins(reward) async def cancel(self): """ diff --git a/bot/modules/accountability/tracker.py b/bot/modules/accountability/tracker.py index 51713230..24e1dc94 100644 --- a/bot/modules/accountability/tracker.py +++ b/bot/modules/accountability/tracker.py @@ -374,14 +374,15 @@ async def _accountability_system_resume(): None, mow.slotid, mow.userid) for mow in slot_members[row.slotid] if mow.last_joined_at ) - slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load( - memberids=[mow.userid for mow in slot_members[row.slotid]] - ) + if client.get_guild(row.guildid): + slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load( + memberids=[mow.userid for mow in slot_members[row.slotid]] + ) + try: + await slot.close() + except discord.HTTPException: + pass row.closed_at = now - try: - await slot.close() - except discord.HTTPException: - pass # Load the in-progress room data if current_room_data: @@ -451,7 +452,7 @@ async def launch_accountability_system(client): guilds = tables.guild_config.fetch_rows_where( accountability_category=NOTNULL ) - [AccountabilityGuild(guild.guildid) for guild in guilds] + [AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)] await _accountability_system_resume() asyncio.create_task(_accountability_loop()) From 0e62ebdb2b54e7f14feede7e63e6225db01e70e4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 14:58:57 +0200 Subject: [PATCH 02/56] fix (rroles): Increase maximum role price. --- bot/modules/guild_admin/reaction_roles/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/modules/guild_admin/reaction_roles/settings.py b/bot/modules/guild_admin/reaction_roles/settings.py index b678b804..dbb4b8cf 100644 --- a/bot/modules/guild_admin/reaction_roles/settings.py +++ b/bot/modules/guild_admin/reaction_roles/settings.py @@ -199,6 +199,7 @@ class price(setting_types.Integer, ReactionSetting): ) accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default." + _max = 2 ** 20 @property def default(self): From 4c21160b31eb669df2b420fd861f9740ac3c26b8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 15:00:04 +0200 Subject: [PATCH 03/56] fix (rroles): Repair setting error reply. Modified `error_reply` utility so `kwargs` are passed to `Embed`. Added `send_args` kwarg to `error_reply`. Fixed an rroles issue where `UserInputError` handling would fail. --- bot/modules/guild_admin/reaction_roles/command.py | 6 +++--- bot/utils/ctx_addons.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py index 61e4a1ed..7f460262 100644 --- a/bot/modules/guild_admin/reaction_roles/command.py +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -821,8 +821,8 @@ async def cmd_reactionroles(ctx, flags): setting = await setting_class.parse(target.messageid, ctx, flags[flag]) except UserInputError as e: return await ctx.error_reply( - title="Couldn't save settings!", - description="{} {}\nNo settings were modified.".format(cross, e.msg) + "{} {}\nNo settings were modified.".format(cross, e.msg), + title="Couldn't save settings!" ) else: update_lines.append( @@ -861,8 +861,8 @@ async def cmd_reactionroles(ctx, flags): setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag]) except UserInputError as e: return await ctx.error_reply( + "{} {}\nNo reaction roles were modified.".format(cross, e.msg), title="Couldn't save reaction role settings!", - description="{} {}\nNo reaction roles were modified.".format(cross, e.msg) ) else: update_lines.append( diff --git a/bot/utils/ctx_addons.py b/bot/utils/ctx_addons.py index 931422d9..9697eeec 100644 --- a/bot/utils/ctx_addons.py +++ b/bot/utils/ctx_addons.py @@ -26,21 +26,22 @@ async def embed_reply(ctx, desc, colour=discord.Colour.orange(), **kwargs): @Context.util -async def error_reply(ctx, error_str, **kwargs): +async def error_reply(ctx, error_str, send_args={}, **kwargs): """ Notify the user of a user level error. Typically, this will occur in a red embed, posted in the command channel. """ embed = discord.Embed( colour=discord.Colour.red(), - description=error_str + description=error_str, + **kwargs ) message = None try: message = await ctx.ch.send( embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False), - **kwargs + **send_args ) ctx.sent_messages.append(message) return message From db6cc078db4d36ba7af2b25208cc1bdab215819a Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 15:05:05 +0200 Subject: [PATCH 04/56] fix (rroles): Off by one error in `maximum`. --- bot/modules/guild_admin/reaction_roles/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/guild_admin/reaction_roles/tracker.py b/bot/modules/guild_admin/reaction_roles/tracker.py index 6d130263..f18e3c34 100644 --- a/bot/modules/guild_admin/reaction_roles/tracker.py +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -272,7 +272,7 @@ class ReactionRoleMessage: # Fetch the number of applicable roles the user has roleids = set(reaction.data.roleid for reaction in self.reactions) member_roleids = set(role.id for role in member.roles) - if len(roleids.intersection(member_roleids)) > maximum: + if len(roleids.intersection(member_roleids)) >= maximum: # Notify the user embed = discord.Embed( title="Maximum group roles reached!", From e797b67c3b12375d519791197fcb82ff40aa3680 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 15:09:06 +0200 Subject: [PATCH 05/56] fix (rooms): Remove `speak` permission from room. --- bot/modules/accountability/TimeSlot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index 2a87826a..9464e4e5 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -69,7 +69,8 @@ class TimeSlot: _everyone_overwrite = discord.PermissionOverwrite( view_channel=False, - connect=False + connect=False, + speak=False ) happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png" From 0fbf7c8903fac61edfe9a3febb5a3635bff2424e Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 15:23:14 +0200 Subject: [PATCH 06/56] fix (seekers): Consider news channels as text. Non-canonical hack to `find_channel` to include `news` types in `text`. --- bot/modules/guild_admin/reaction_roles/command.py | 2 +- bot/utils/seekers.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py index 7f460262..0f915ffa 100644 --- a/bot/modules/guild_admin/reaction_roles/command.py +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -485,7 +485,7 @@ async def cmd_reactionroles(ctx, flags): await ctx.error_reply( "The provided channel no longer exists!" ) - elif channel.type != discord.ChannelType.text: + elif not isinstance(channel, discord.TextChannel): await ctx.error_reply( "The provided channel is not a text channel!" ) diff --git a/bot/utils/seekers.py b/bot/utils/seekers.py index 69bb1f46..7f3d49d3 100644 --- a/bot/utils/seekers.py +++ b/bot/utils/seekers.py @@ -182,7 +182,11 @@ async def find_channel(ctx, userstr, interactive=False, collection=None, chan_ty # Create the collection to search from args or guild channels collection = collection if collection else ctx.guild.channels if chan_type is not None: - collection = [chan for chan in collection if chan.type == chan_type] + if chan_type == discord.ChannelType.text: + # Hack to support news channels as text channels + collection = [chan for chan in collection if isinstance(chan, discord.TextChannel)] + else: + collection = [chan for chan in collection if chan.type == chan_type] # If the user input was a number or possible channel mention, extract it chanid = userstr.strip('<#@&!>') @@ -413,7 +417,7 @@ async def find_message(ctx, msgid, chlist=None, ignore=[]): async def _search_in_channel(channel: discord.TextChannel, msgid: int): - if channel.type != discord.ChannelType.text: + if not isinstance(channel, discord.TextChannel): return try: message = await channel.fetch_message(msgid) From 0b5be79b6954f6615fca7e06ce52d76164b2bfda Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 16:12:35 +0200 Subject: [PATCH 07/56] (LionModule): Improve startup handling. Update `cmdClient` pointer for module launch updates. Implement module launch wait logic in `pre_command`. Add details to `SafeCancellation` calls in `pre_command`. --- bot/LionModule.py | 25 +++++++++++++++++++------ bot/cmdClient | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/bot/LionModule.py b/bot/LionModule.py index 2d6fb854..c1fd7256 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -13,7 +13,7 @@ class LionCommand(Command): """ Subclass to allow easy attachment of custom hooks and structure to commands. """ - ... + allow_before_ready = False class LionModule(Module): @@ -72,25 +72,38 @@ class LionModule(Module): """ Lion pre-command hook. """ + if not self.ready and not ctx.cmd.allow_before_ready: + try: + await ctx.embed_reply( + "I am currently restarting! Please try again in a couple of minutes." + ) + except discord.HTTPException: + pass + raise SafeCancellation(details="Module '{}' is not ready.".format(self.name)) + + # Check that the channel and guild still exists + if not ctx.client.get_guild(ctx.guild.id) or not ctx.guild.get_channel(ctx.ch.id): + raise SafeCancellation(details='Command channel is no longer reachable.') + # Check global user blacklist if ctx.author.id in ctx.client.objects['blacklisted_users']: - raise SafeCancellation + raise SafeCancellation(details='User is blacklisted.') if ctx.guild: # Check global guild blacklist if ctx.guild.id in ctx.client.objects['blacklisted_guilds']: - raise SafeCancellation + raise SafeCancellation(details='Guild is blacklisted.') # Check guild's own member blacklist if ctx.author.id in ctx.client.objects['ignored_members'][ctx.guild.id]: - raise SafeCancellation + raise SafeCancellation(details='User is ignored in this guild.') # Check channel permissions are sane if not ctx.ch.permissions_for(ctx.guild.me).send_messages: - raise SafeCancellation + raise SafeCancellation(details='I cannot send messages in this channel.') if not ctx.ch.permissions_for(ctx.guild.me).embed_links: await ctx.reply("I need permission to send embeds in this channel before I can run any commands!") - raise SafeCancellation + raise SafeCancellation(details='I cannot send embeds in this channel.') # Start typing await ctx.ch.trigger_typing() diff --git a/bot/cmdClient b/bot/cmdClient index 75410acc..6eb42690 160000 --- a/bot/cmdClient +++ b/bot/cmdClient @@ -1 +1 @@ -Subproject commit 75410acc120b456ff315ff468357b6fb22a0a406 +Subproject commit 6eb426903423d6be8439621eb0b906aa94957efd From fc3246913fb9c1a6be285af9b2eb906438dac90b Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 16:27:38 +0200 Subject: [PATCH 08/56] fix (core): Update `guild_config` definition. Increase the cache size, and update the columns. --- bot/core/data.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index c33914d7..992e9ab5 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -53,13 +53,19 @@ def add_pending(pending): guild_config = RowTable( 'guild_config', - ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', + ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel', + 'studyban_role', 'min_workout_length', 'workout_reward', 'max_tasks', 'task_reward', 'task_reward_limit', 'study_hourly_reward', 'study_hourly_live_bonus', - 'study_ban_role', 'max_study_bans'), + 'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms', + 'accountability_category', 'accountability_lobby', 'accountability_bonus', + 'accountability_reward', 'accountability_price', + 'video_studyban', 'video_grace_period', + 'greeting_channel', 'greeting_message', 'returning_message', + 'starting_funds', 'persist_roles'), 'guildid', - cache=TTLCache(1000, ttl=60*5) + cache=TTLCache(2500, ttl=60*5) ) unranked_roles = Table('unranked_roles') @@ -72,6 +78,7 @@ lions = RowTable( ('guildid', 'userid', 'tracked_time', 'coins', 'workout_count', 'last_workout_start', + 'revision_mute_count', 'last_study_badgeid', 'video_warned', '_timestamp' From 2cf66ab6004ae347f9a85dd572e43632eeb5f2d7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 7 Nov 2021 16:31:55 +0200 Subject: [PATCH 09/56] (stats_cmd): Allow execution before ready. --- bot/modules/study/stats_cmd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index 90768202..73604658 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -12,7 +12,8 @@ from .module import module @module.cmd( "stats", group="Statistics", - desc="View a summary of your study statistics!" + desc="View a summary of your study statistics!", + allow_before_ready=True ) @in_guild() async def cmd_stats(ctx): From 6bc831a5497e8b23cc59ef17bf95bf181b30aebe Mon Sep 17 00:00:00 2001 From: Harsha Raghu Date: Thu, 11 Nov 2021 19:11:37 +0530 Subject: [PATCH 10/56] Added standard .gitignore for project Standard python .gitignore: https://github.com/github/gitignore/blob/master/Python.gitignore and to ignore changes to `config/**` dir (hiding bot.conf file) --- .gitignore | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..978964e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +config/** \ No newline at end of file From 9c8dfd6a3af036372ab071adae5a30a5fa5507ac Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 29 Nov 2021 13:31:37 +0200 Subject: [PATCH 11/56] (sessions): Complete launch and init pathway. --- bot/modules/study/tracking/session_tracker.py | 126 +++++++++++++++++- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index e44a2bc5..fb9b9341 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -1,14 +1,23 @@ import asyncio import discord +import logging +import traceback from collections import defaultdict from utils.lib import utc_now +from data import tables +from core import Lion + from ..module import module -from .data import current_sessions +from .data import current_sessions, SessionChannelType from .settings import untracked_channels, hourly_reward, hourly_live_bonus, max_daily_study class Session: + """ + A `Session` is a guild member that is currently studying (i.e. that is in a tracked voice channel). + This class acts as an opaque interface to the corresponding `sessions` data row. + """ # TODO: Slots sessions = defaultdict(dict) @@ -37,13 +46,20 @@ class Session: if userid in cls.sessions[guildid]: raise ValueError("A session for this member already exists!") # TODO: Handle daily study cap - # TODO: Calculate channel type - # TODO: Ensure lion + + # TODO: More reliable channel type determination + if state.channel.id in tables.rented.row_cache: + channel_type = SessionChannelType.RENTED + elif state.channel.id in tables.accountability_rooms.row_cache: + channel_type = SessionChannelType.ACCOUNTABILITY + else: + channel_type = SessionChannelType.STANDARD + current_sessions.create_row( guildid=guildid, userid=userid, channelid=state.channel.id, - channel_type=None, + channel_type=channel_type, start_time=now, live_start=now if (state.self_video or state.self_stream) else None, stream_start=now if state.self_stream else None, @@ -101,6 +117,7 @@ async def session_voice_tracker(client, member, before, after): Voice update event dispatcher for study session tracking. """ guild = member.guild + Lion.fetch(guild.id, member.id) session = Session.get(guild.id, member.id) if before.channel == after.channel: @@ -112,6 +129,7 @@ async def session_voice_tracker(client, member, before, after): # Member changed channel # End the current session and start a new one, if applicable # TODO: Max daily study session tasks + # TODO: Error if before is None but we have a current session if session: # End the current session session.finish() @@ -135,8 +153,108 @@ async def _init_session_tracker(client): update them depending on the current voice states, and attach the voice event handler. """ + # Ensure the client caches are ready and guilds are chunked await client.wait_until_ready() + + # Pre-cache the untracked channels await untracked_channels.launch_task(client) + + # Log init start and define logging counters + client.log( + "Loading ongoing study sessions.", + context="SESSION_INIT", + level=logging.DEBUG + ) + resumed = 0 + ended = 0 + + # Grab all ongoing sessions from data + rows = current_sessions.fetch_rows_where() + + # Iterate through, resume or end as needed + for row in rows: + if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None: + try: + # Load the Session + session = Session(row.guildid, row.userid) + + # Find the channel and member voice state + voice = None + if channel := guild.get_channel(row.channelid): + voice = next((member.voice for member in channel.members if member.id == row.userid), None) + + # Resume or end as required + if voice and voice.channel: + client.log( + "Resuming ongoing session: {}".format(row), + context="SESSION_INIT", + level=logging.DEBUG + ) + Session.sessions[row.guildid][row.userid] = session + session.save_live_status(voice) + resumed += 1 + else: + client.log( + "Ending already completed session: {}".format(row), + context="SESSION_INIT", + level=logging.DEBUG + ) + session.finish() + ended += 1 + except Exception: + # Fatal error + client.log( + "Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()), + context="SESSION_INIT", + level=logging.CRITICAL + ) + module.ready = False + return + + # Log resumed sessions + client.log( + "Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended), + context="SESSION_INIT", + level=logging.INFO + ) + + # Now iterate through members of all tracked voice channels + # Start sessions if they don't already exist + tracked_channels = [ + channel + for guild in client.guilds + for channel in guild.voice_channels + if channel.members and channel.id not in untracked_channels.get(guild.id).data + ] + new_members = [ + member + for channel in tracked_channels + for member in channel.members + if not Session.get(member.guild.id, member.id) + ] + for member in new_members: + client.log( + "Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format( + member.name, + member.id, + member.voice.channel.name, + member.voice.channel.id, + member.guild.name, + member.guild.id + ), + context="SESSION_INIT", + level=logging.DEBUG + ) + Session.start(member, member.voice) + + # Log newly started sessions + client.log( + "Started {} new study sessions from current voice channel members.".format(len(new_members)), + context="SESSION_INIT", + level=logging.INFO + ) + + # Now that we are in a valid initial state, attach the session event handler client.add_after_event("voice_state_update", session_voice_tracker) From ac71c4da9bd50ea6fe8eced4a28e62c16fe9a8ec Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 29 Nov 2021 13:33:21 +0200 Subject: [PATCH 12/56] (sessions): Add `study_time_since` function. New `study_time_since` database function from session history. Add `Lion.timezone`. Add `Lion.day_start`. Add `Lion.studied_today`. Made `Table.queries` an instance variable. Renamed the session channel types. --- bot/core/data.py | 62 +++++++++++++++--------------- bot/core/lion.py | 35 +++++++++++++++++ bot/data/interfaces.py | 2 +- bot/modules/study/tracking/data.py | 27 +++++++++++++ data/migration/v5-v6/migration.sql | 62 ++++++++++++++++++++++++------ data/schema.sql | 40 ++++++++++++++++++- 6 files changed, 184 insertions(+), 44 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index c33914d7..8ae74874 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -20,37 +20,6 @@ user_config = RowTable( ) -@user_config.save_query -def add_pending(pending): - """ - pending: - List of tuples of the form `(userid, pending_coins, pending_time)`. - """ - with lions.conn: - cursor = lions.conn.cursor() - data = execute_values( - cursor, - """ - UPDATE members - SET - coins = coins + t.coin_diff, - tracked_time = tracked_time + t.time_diff - FROM - (VALUES %s) - AS - t (guildid, userid, coin_diff, time_diff) - WHERE - members.guildid = t.guildid - AND - members.userid = t.userid - RETURNING * - """, - pending, - fetch=True - ) - return lions._make_rows(*data) - - guild_config = RowTable( 'guild_config', ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', @@ -84,6 +53,37 @@ lions = RowTable( lion_ranks = Table('member_ranks', attach_as='lion_ranks') +@lions.save_query +def add_pending(pending): + """ + pending: + List of tuples of the form `(userid, pending_coins, pending_time)`. + """ + with lions.conn: + cursor = lions.conn.cursor() + data = execute_values( + cursor, + """ + UPDATE members + SET + coins = coins + t.coin_diff, + tracked_time = tracked_time + t.time_diff + FROM + (VALUES %s) + AS + t (guildid, userid, coin_diff, time_diff) + WHERE + members.guildid = t.guildid + AND + members.userid = t.userid + RETURNING * + """, + pending, + fetch=True + ) + return lions._make_rows(*data) + + global_guild_blacklist = Table('global_guild_blacklist') global_user_blacklist = Table('global_user_blacklist') ignored_members = Table('ignored_members') diff --git a/bot/core/lion.py b/bot/core/lion.py index b9b10092..d9eb7ea1 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -1,4 +1,5 @@ import pytz +import datetime from meta import client from data import tables as tb @@ -41,6 +42,7 @@ class Lion: if key in cls._lions: return cls._lions[key] else: + # TODO: Debug log lion = tb.lions.fetch(key) if not lion: tb.lions.create_row( @@ -95,6 +97,39 @@ class Lion: """ return int(self.data.coins + self._pending_coins) + @property + def session(self): + """ + The current study session the user is in, if any. + """ + if 'sessions' not in client.objects: + raise ValueError("Cannot retrieve session before Study module is initialised!") + return client.objects['sessions'][self.guildid].get(self.userid, None) + + @property + def timezone(self): + """ + The user's configured timezone. + Shortcut to `Lion.settings.timezone.value`. + """ + return self.settings.timezone.value + + @property + def day_start(self): + """ + A timezone aware datetime representing the start of the user's day (in their configured timezone). + """ + now = datetime.datetime.now(tz=self.timezone) + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + @property + def studied_today(self): + """ + The amount of time, in seconds, that the member has studied today. + Extracted from the session history. + """ + return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start) + def localize(self, naive_utc_dt): """ Localise the provided naive UTC datetime into the user's timezone. diff --git a/bot/data/interfaces.py b/bot/data/interfaces.py index 42810e72..7673b0e0 100644 --- a/bot/data/interfaces.py +++ b/bot/data/interfaces.py @@ -45,10 +45,10 @@ class Table: Intended to be subclassed to provide more derivative access for specific tables. """ conn = conn - queries = DotDict() def __init__(self, name, attach_as=None): self.name = name + self.queries = DotDict() tables[attach_as or name] = self @_connection_guard diff --git a/bot/modules/study/tracking/data.py b/bot/modules/study/tracking/data.py index deab3816..5940bf63 100644 --- a/bot/modules/study/tracking/data.py +++ b/bot/modules/study/tracking/data.py @@ -1,7 +1,21 @@ from data import Table, RowTable, tables +from utils.lib import FieldEnum + untracked_channels = Table('untracked_channels') + +class SessionChannelType(FieldEnum): + """ + The possible session channel types. + """ + # NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name + STANDARD = 'STANDARD', "Standard" + ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room" + RENTED = 'RENTED', "Private Room" + EXTERNAL = 'EXTERNAL', "Unknown" + + session_history = Table('session_history') current_sessions = RowTable( 'current_sessions', @@ -30,3 +44,16 @@ def close_study_session(guildid, userid): current_sessions.row_cache.pop((guildid, userid), None) # Use the function output to update the member cache tables.lions._make_rows(*rows) + + +@session_history.save_query +def study_time_since(guildid, userid, timestamp): + """ + Retrieve the total member study time (in seconds) since the given timestamp. + Includes the current session, if it exists. + """ + with session_history.conn as conn: + cursor = conn.cursor() + cursor.callproc('study_time_since', (guildid, userid, timestamp)) + rows = cursor.fetchall() + return (rows[0][0] if rows else None) or 0 diff --git a/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 70ec3843..9e404e13 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -1,19 +1,22 @@ -/* DROP TYPE IF EXISTS SessionChannelType CASCADE; */ -/* DROP TABLE IF EXISTS session_history CASCADE; */ -/* DROP TABLE IF EXISTS current_sessions CASCADE; */ -/* DROP FUNCTION IF EXISTS close_study_session; */ +DROP TYPE IF EXISTS SessionChannelType CASCADE; +DROP TABLE IF EXISTS session_history CASCADE; +DROP TABLE IF EXISTS current_sessions CASCADE; +DROP FUNCTION IF EXISTS close_study_session; + +DROP VIEW IF EXISTS current_sessions_totals CASCADE; +DROP VIEW IF EXISTS member_totals CASCADE; +DROP VIEW IF EXISTS member_ranks CASCADE; +DROP VIEW IF EXISTS current_study_badges CASCADE; +DROP VIEW IF EXISTS new_study_badges CASCADE; + +DROP FUNCTION IF EXISTS study_time_since; -/* DROP VIEW IF EXISTS current_sessions_totals CASCADE; */ -/* DROP VIEW IF EXISTS member_totals CASCADE; */ -/* DROP VIEW IF EXISTS member_ranks CASCADE; */ -/* DROP VIEW IF EXISTS current_study_badges CASCADE; */ -/* DROP VIEW IF EXISTS new_study_badges CASCADE; */ CREATE TYPE SessionChannelType AS ENUM ( + 'STANDARD', 'ACCOUNTABILITY', 'RENTED', - 'EXTERNAL', - 'MIGRATED' + 'EXTERNAL' ); CREATE TABLE session_history( @@ -134,3 +137,40 @@ CREATE VIEW new_study_badges AS WHERE last_study_badgeid IS DISTINCT FROM current_study_badgeid ORDER BY guildid; + + +CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + RETURNS INTEGER +AS $$ + BEGIN + RETURN ( + SELECT + SUM( + CASE + WHEN start_time >= _timestamp THEN duration + ELSE EXTRACT(EPOCH FROM (end_time - _timestamp)) + END + ) + FROM ( + SELECT + start_time, + duration, + (start_time + duration * interval '1 second') AS end_time + FROM session_history + WHERE + guildid=_guildid + AND userid=_userid + AND (start_time + duration * interval '1 second') >= _timestamp + UNION + SELECT + start_time, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, + NOW() AS end_time + FROM current_sessions + WHERE + guildid=_guildid + AND userid=_userid + ) AS sessions + ); + END; +$$ LANGUAGE PLPGSQL; diff --git a/data/schema.sql b/data/schema.sql index aba91dee..51ba3ecd 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -412,12 +412,13 @@ update_timestamp_column(); -- Study Session Data {{{ CREATE TYPE SessionChannelType AS ENUM ( + 'STANDARD', 'ACCOUNTABILITY', 'RENTED', 'EXTERNAL', - 'MIGRATED' ); + CREATE TABLE session_history( sessionid SERIAL PRIMARY KEY, guildid BIGINT NOT NULL, @@ -453,6 +454,43 @@ CREATE TABLE current_sessions( CREATE UNIQUE INDEX current_session_members ON current_sessions (guildid, userid); +CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + RETURNS INTEGER +AS $$ + BEGIN + RETURN ( + SELECT + SUM( + CASE + WHEN start_time >= _timestamp THEN duration + ELSE EXTRACT(EPOCH FROM (end_time - _timestamp)) + END + ) + FROM ( + SELECT + start_time, + duration, + (start_time + duration * interval '1 second') AS end_time + FROM session_history + WHERE + guildid=_guildid + AND userid=_userid + AND (start_time + duration * interval '1 second') >= _timestamp + UNION + SELECT + start_time, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, + NOW() AS end_time + FROM current_sessions + WHERE + guildid=_guildid + AND userid=_userid + ) AS sessions + ); + END; +$$ LANGUAGE PLPGSQL; + + CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT) RETURNS SETOF members AS $$ From 734436e2a61e55e2fb00bf8452d67f9e35d9b17f Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 11:48:04 +0200 Subject: [PATCH 13/56] (sessions): Add `daily_study_cap` system. --- bot/core/data.py | 2 +- bot/core/lion.py | 36 +++- bot/modules/study/tracking/session_tracker.py | 177 ++++++++++++++++-- bot/modules/study/tracking/settings.py | 14 +- data/migration/v5-v6/migration.sql | 2 + data/schema.sql | 2 +- 6 files changed, 207 insertions(+), 26 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index 8ae74874..cd3f850b 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -25,7 +25,7 @@ guild_config = RowTable( ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'alert_channel', 'min_workout_length', 'workout_reward', 'max_tasks', 'task_reward', 'task_reward_limit', - 'study_hourly_reward', 'study_hourly_live_bonus', + 'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap', 'study_ban_role', 'max_study_bans'), 'guildid', cache=TTLCache(1000, ttl=60*5) diff --git a/bot/core/lion.py b/bot/core/lion.py index d9eb7ea1..15bed9e0 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -1,5 +1,5 @@ import pytz -import datetime +from datetime import datetime, timedelta from meta import client from data import tables as tb @@ -79,10 +79,17 @@ class Lion: @property def settings(self): """ - The UserSettings object for this user. + The UserSettings interface for this member. """ return UserSettings(self.userid) + @property + def guild_settings(self): + """ + The GuildSettings interface for this member. + """ + return GuildSettings(self.guildid) + @property def time(self): """ @@ -118,10 +125,15 @@ class Lion: def day_start(self): """ A timezone aware datetime representing the start of the user's day (in their configured timezone). + NOTE: This might not be accurate over DST boundaries. """ - now = datetime.datetime.now(tz=self.timezone) + now = datetime.now(tz=self.timezone) return now.replace(hour=0, minute=0, second=0, microsecond=0) + @property + def remaining_in_day(self): + return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() + @property def studied_today(self): """ @@ -130,6 +142,24 @@ class Lion: """ return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start) + @property + def remaining_study_today(self): + """ + Maximum remaining time (in seconds) this member can study today. + + May not account for DST boundaries and leap seconds. + """ + studied_today = self.studied_today + study_cap = self.guild_settings.daily_study_cap.value + + remaining_in_day = self.remaining_in_day + if remaining_in_day >= (study_cap - studied_today): + remaining = study_cap - studied_today + else: + remaining = remaining_in_day + study_cap + + return remaining + def localize(self, naive_utc_dt): """ Localise the provided naive UTC datetime into the user's timezone. diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index fb9b9341..146f1195 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -2,29 +2,42 @@ import asyncio import discord import logging import traceback +from typing import Dict from collections import defaultdict from utils.lib import utc_now from data import tables from core import Lion +from meta import client from ..module import module from .data import current_sessions, SessionChannelType -from .settings import untracked_channels, hourly_reward, hourly_live_bonus, max_daily_study +from .settings import untracked_channels, hourly_reward, hourly_live_bonus class Session: """ - A `Session` is a guild member that is currently studying (i.e. that is in a tracked voice channel). + A `Session` describes an ongoing study session by a single guild member. + A member is counted as studying when they are in a tracked voice channel. + This class acts as an opaque interface to the corresponding `sessions` data row. """ - # TODO: Slots - sessions = defaultdict(dict) + __slots__ = ( + 'guildid', + 'userid', + '_expiry_task' + ) + # Global cache of ongoing sessions + sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict) + + # Global cache of members pending session start (waiting for daily cap reset) + members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict) def __init__(self, guildid, userid): self.guildid = guildid self.userid = userid - self.key = (guildid, userid) + + self._expiry_task: asyncio.Task = None @classmethod def get(cls, guildid, userid): @@ -45,7 +58,22 @@ class Session: if userid in cls.sessions[guildid]: raise ValueError("A session for this member already exists!") - # TODO: Handle daily study cap + + # If the user is study capped, schedule the session start for the next day + if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 0: + if pending := cls.members_pending[guildid].pop(userid, None): + pending.cancel() + task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state)) + cls.members_pending[guildid][userid] = task + client.log( + "Member (uid:{}) in (gid:{}) is study capped, " + "delaying session start for {} seconds until start of next day.".format( + userid, guildid, lion.remaining_in_day + ), + context="SESSION_TRACKER", + level=logging.DEBUG + ) + return # TODO: More reliable channel type determination if state.channel.id in tables.rented.row_cache: @@ -67,23 +95,104 @@ class Session: hourly_coins=hourly_reward.get(guildid).value, hourly_live_coins=hourly_live_bonus.get(guildid).value ) - session = cls(guildid, userid) - cls.sessions[guildid][userid] = session - return session + session = cls(guildid, userid).activate() + client.log( + "Started session: {}".format(session.data), + context="SESSION_TRACKER", + level=logging.DEBUG, + ) + + @classmethod + async def _delayed_start(cls, guildid, userid, *args): + delay = Lion.fetch(guildid, userid).remaining_in_day + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + pass + else: + cls.start(*args) + + @property + def key(self): + """ + RowTable Session identification key. + """ + return (self.guildid, self.userid) + + @property + def lion(self): + """ + The Lion member object associated with this member. + """ + return Lion.fetch(self.guildid, self.userid) @property def data(self): return current_sessions.fetch(self.key) + def activate(self): + """ + Activate the study session. + This adds the session to the studying members cache, + and schedules the session expiry, based on the daily study cap. + """ + # Add to the active cache + self.sessions[self.guildid][self.userid] = self + + # Schedule the session expiry + self.schedule_expiry() + + # Return self for easy chaining + return self + + def schedule_expiry(self): + """ + Schedule session termination when the user reaches the maximum daily study time. + """ + asyncio.create_task(self._schedule_expiry()) + + async def _schedule_expiry(self): + # Cancel any existing expiry + if self._expiry_task and not self._expiry_task.done(): + self._expiry_task.cancel() + + # Wait for the maximum session length + try: + self._expiry_task = await asyncio.sleep(self.lion.remaining_study_today) + except asyncio.CancelledError: + pass + else: + if self.lion.remaining_study_today <= 0: + # End the session + # Note that the user will not automatically start a new session when the day starts + # TODO: Notify user? Disconnect them? + client.log( + "Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format( + self.userid, self.guildid, self.data + ), + context="SESSION_TRACKER" + ) + self.finish() + else: + # It's possible the expiry time was pushed forwards while waiting + # If so, reschedule + self.schedule_expiry() + def finish(self): """ Close the study session. """ - self.sessions[self.guildid].pop(self.userid, None) # Note that save_live_status doesn't need to be called here # The database saving procedure will account for the values. current_sessions.queries.close_study_session(*self.key) + # Remove session from active cache + self.sessions[self.guildid].pop(self.userid, None) + + # Cancel any existing expiry task + if self._expiry_task and not self._expiry_task.done(): + self._expiry_task.cancel() + def save_live_status(self, state: discord.VoiceState): """ Update the saved live status of the member. @@ -128,11 +237,43 @@ async def session_voice_tracker(client, member, before, after): else: # Member changed channel # End the current session and start a new one, if applicable - # TODO: Max daily study session tasks - # TODO: Error if before is None but we have a current session if session: + if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id): + client.log( + "The previous voice state for " + "member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) " + "does not match their current study session!\n" + "Session channel is (cid:{scid}), but the previous channel is {previous}.".format( + member=member, + guild=member.guild, + scid=scid, + previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None" + ), + context="SESSION_TRACKER", + level=logging.ERROR + ) + client.log( + "Ending study session for {member.name} (uid:{member.id}) " + "in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format( + member=member, + session=session.data + ), + context="SESSION_TRACKER", + post=False + ) # End the current session session.finish() + elif pending := Session.members_pending[guild.id].pop(member.id, None): + client.log( + "Cancelling pending study session for {member.name} (uid:{member.id}) " + "in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format( + member=member + ), + context="SESSION_TRACKER", + post=False + ) + pending.cancel() + if after.channel: blacklist = client.objects['blacklisted_users'] guild_blacklist = client.objects['ignored_members'][guild.id] @@ -144,7 +285,15 @@ async def session_voice_tracker(client, member, before, after): ) if start_session: # Start a new session for the member - Session.start(member, after) + client.log( + "Starting a new voice channel study session for {member.name} (uid:{member.id}) " + "in {member.guild.name} (gid:{member.guild.id}).".format( + member=member, + ), + context="SESSION_TRACKER", + post=False + ) + session = Session.start(member, after) async def _init_session_tracker(client): @@ -190,7 +339,7 @@ async def _init_session_tracker(client): context="SESSION_INIT", level=logging.DEBUG ) - Session.sessions[row.guildid][row.userid] = session + session.activate() session.save_live_status(voice) resumed += 1 else: diff --git a/bot/modules/study/tracking/settings.py b/bot/modules/study/tracking/settings.py index dc93cc00..d7d583ab 100644 --- a/bot/modules/study/tracking/settings.py +++ b/bot/modules/study/tracking/settings.py @@ -1,5 +1,3 @@ -from collections import defaultdict - import settings from settings import GuildSettings from wards import guild_admin @@ -52,10 +50,10 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti if any(channel.members for channel in guild.voice_channels) ] if active_guildids: + cache = {guildid: [] for guildid in active_guildids} rows = cls._table_interface.select_where( guildid=active_guildids ) - cache = defaultdict(list) for row in rows: cache[row['guildid']].append(row['channelid']) cls._cache.update(cache) @@ -114,11 +112,13 @@ class hourly_live_bonus(settings.Integer, settings.GuildSetting): @GuildSettings.attach_setting -class max_daily_study(settings.Duration, settings.GuildSetting): +class daily_study_cap(settings.Duration, settings.GuildSetting): category = "Study Tracking" - attr_name = "max_daily_study" - _data_column = "max_daily_study" + attr_name = "daily_study_cap" + _data_column = "daily_study_cap" - display_name = "max_daily_study" + display_name = "daily_study_cap" desc = "Maximum amount of study time ..." + + _default = 16 * 60 * 60 diff --git a/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 9e404e13..3e64007a 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -174,3 +174,5 @@ AS $$ ); END; $$ LANGUAGE PLPGSQL; + +ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER; diff --git a/data/schema.sql b/data/schema.sql index 51ba3ecd..9f821f01 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -78,7 +78,7 @@ CREATE TABLE guild_config( returning_message TEXT, starting_funds INTEGER, persist_roles BOOLEAN, - max_daily_study INTEGER + daily_study_cap INTEGER ); CREATE TABLE ignored_members( From 3cb9d97e1ecde33d29409053d447d3360672bf27 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 12:01:12 +0200 Subject: [PATCH 14/56] fix: Improve launch caching systems. --- bot/core/module.py | 5 +++-- bot/modules/moderation/video/admin.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/core/module.py b/bot/core/module.py index 0bf870c0..2f51408a 100644 --- a/bot/core/module.py +++ b/bot/core/module.py @@ -61,9 +61,10 @@ async def preload_studying_members(client): """ userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members)) if userids: - rows = client.data.lions.fetch_rows_where(userid=userids) + users = client.data.user_config.fetch_rows_where(userid=userids) + members = client.data.lions.fetch_rows_where(userid=userids) client.log( - "Preloaded member data for {} members.".format(len(rows)), + "Preloaded data for {} user with {} members.".format(len(users), len(members)), context="CORE_LOADING" ) diff --git a/bot/modules/moderation/video/admin.py b/bot/modules/moderation/video/admin.py index 1f0ddaab..8d658c60 100644 --- a/bot/modules/moderation/video/admin.py +++ b/bot/modules/moderation/video/admin.py @@ -56,10 +56,10 @@ class video_channels(settings.ChannelList, settings.ListData, settings.Setting): if any(channel.members for channel in guild.voice_channels) ] if active_guildids: + cache = {guildid: [] for guildid in active_guildids} rows = cls._table_interface.select_where( guildid=active_guildids ) - cache = defaultdict(list) for row in rows: cache[row['guildid']].append(row['channelid']) cls._cache.update(cache) From 3abac6337956c66b6caa222ae7e4809dd6f3ecf5 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 12:50:23 +0200 Subject: [PATCH 15/56] style (config): Add closest-match suggestions. --- bot/modules/guild_admin/guild_config.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/modules/guild_admin/guild_config.py b/bot/modules/guild_admin/guild_config.py index 608786fb..c79d62bb 100644 --- a/bot/modules/guild_admin/guild_config.py +++ b/bot/modules/guild_admin/guild_config.py @@ -1,3 +1,4 @@ +import difflib import discord from cmdClient.lib import SafeCancellation @@ -121,9 +122,15 @@ async def cmd_config(ctx, flags): name = parts[0] setting = setting_displaynames.get(name.lower(), None) if setting is None: + matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2) + match = "`{}`".format('` or `'.join(matches)) if matches else None return await ctx.error_reply( - "Server setting `{}` doesn't exist! Use `{}config` to see all server settings".format( - name, ctx.best_prefix + "Couldn't find a setting called `{}`!\n" + "{}" + "Use `{}config info` to see all the server settings.".format( + name, + "Maybe you meant {}?\n".format(match) if match else "", + ctx.best_prefix ) ) From fe1cd85511d1ac81c33f32c99ba6ea08d9461cfe Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 12:51:14 +0200 Subject: [PATCH 16/56] (sessions): Add UI for `daily_study_cap`. --- bot/modules/study/tracking/settings.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/bot/modules/study/tracking/settings.py b/bot/modules/study/tracking/settings.py index d7d583ab..53f7ecf8 100644 --- a/bot/modules/study/tracking/settings.py +++ b/bot/modules/study/tracking/settings.py @@ -119,6 +119,23 @@ class daily_study_cap(settings.Duration, settings.GuildSetting): _data_column = "daily_study_cap" display_name = "daily_study_cap" - desc = "Maximum amount of study time ..." + desc = "Maximum amount of recorded study time per member per day." _default = 16 * 60 * 60 + _default_multiplier = 60 * 60 + + _max = 25 * 60 * 60 + + long_desc = ( + "The maximum amount of study time that can be recorded for a member per day, " + "intended to remove system encouragement for unhealthy or obsessive behaviour.\n" + "The member may study for longer, but their sessions will not be tracked. " + "The start and end of the day are determined by the member's configured timezone." + ) + + @property + def success_response(self): + # Refresh expiry for all sessions in the guild + [session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()] + + return "The maximum tracked daily study time is now {}.".format(self.formatted) From cffdfb693b730780d4b6fe08a18ab0f8ecce4676 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 2 Dec 2021 13:16:36 +0200 Subject: [PATCH 17/56] (sessions): Add guild join and leave hooks. --- bot/modules/study/tracking/session_tracker.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 146f1195..24ffde49 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -296,6 +296,63 @@ async def session_voice_tracker(client, member, before, after): session = Session.start(member, after) +async def leave_guild_sessions(client, guild): + """ + `guild_leave` hook. + Close all sessions in the guild when we leave. + """ + sessions = list(Session.sessions[guild.id].values()) + for session in sessions: + session.finish() + client.log( + "Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)), + context="SESSION_TRACKER" + ) + + +async def join_guild_sessions(client, guild): + """ + `guild_join` hook. + Refresh all sessions for the guild when we rejoin. + """ + # Delete existing current sessions, which should have been closed when we left + # It is possible we were removed from the guild during an outage + current_sessions.delete_where(guildid=guild.id) + + untracked = untracked_channels.get(guild.id).data + members = [ + member + for channel in guild.voice_channels + for member in channel.members + if channel.members and channel.id not in untracked + ] + for member in members: + client.log( + "Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format( + member.name, + member.id, + member.voice.channel.name, + member.voice.channel.id, + member.guild.name, + member.guild.id + ), + context="SESSION_TRACKER", + level=logging.INFO, + post=False + ) + Session.start(member, member.voice) + + # Log newly started sessions + client.log( + "Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format( + guild.name, + guild.id, + len(members) + ), + context="SESSION_TRACKER", + ) + + async def _init_session_tracker(client): """ Load ongoing saved study sessions into the session cache, @@ -405,6 +462,8 @@ async def _init_session_tracker(client): # Now that we are in a valid initial state, attach the session event handler client.add_after_event("voice_state_update", session_voice_tracker) + client.add_after_event("guild_remove", leave_guild_sessions) + client.add_after_event("guild_join", join_guild_sessions) @module.launch_task From 144ccf9e81effdf7160edabb1048476df93d2b17 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 4 Dec 2021 10:24:11 +0200 Subject: [PATCH 18/56] (Lion): Update to account for current session. Remove `time` pending and syncing logic. Update `time` and `coins` to account for current session. Add `Session.duration` for current session duration. Add `Session.coins_earned` for current session coins. --- bot/cmdClient | 2 +- bot/core/data.py | 7 ++-- bot/core/lion.py | 40 +++++++++++-------- bot/modules/study/tracking/session_tracker.py | 23 +++++++++++ 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/bot/cmdClient b/bot/cmdClient index 75410acc..6eb42690 160000 --- a/bot/cmdClient +++ b/bot/cmdClient @@ -1 +1 @@ -Subproject commit 75410acc120b456ff315ff468357b6fb22a0a406 +Subproject commit 6eb426903423d6be8439621eb0b906aa94957efd diff --git a/bot/core/data.py b/bot/core/data.py index cd3f850b..013339b8 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -57,7 +57,7 @@ lion_ranks = Table('member_ranks', attach_as='lion_ranks') def add_pending(pending): """ pending: - List of tuples of the form `(userid, pending_coins, pending_time)`. + List of tuples of the form `(guildid, userid, pending_coins)`. """ with lions.conn: cursor = lions.conn.cursor() @@ -66,12 +66,11 @@ def add_pending(pending): """ UPDATE members SET - coins = coins + t.coin_diff, - tracked_time = tracked_time + t.time_diff + coins = coins + t.coin_diff FROM (VALUES %s) AS - t (guildid, userid, coin_diff, time_diff) + t (guildid, userid, coin_diff) WHERE members.guildid = t.guildid AND diff --git a/bot/core/lion.py b/bot/core/lion.py index 15bed9e0..c803cff8 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -12,7 +12,7 @@ class Lion: Mostly acts as a transparent interface to the corresponding Row, but also adds some transaction caching logic to `coins` and `tracked_time`. """ - __slots__ = ('guildid', 'userid', '_pending_coins', '_pending_time', '_member') + __slots__ = ('guildid', 'userid', '_pending_coins', '_member') # Members with pending transactions _pending = {} # userid -> User @@ -25,7 +25,6 @@ class Lion: self.userid = userid self._pending_coins = 0 - self._pending_time = 0 self._member = None @@ -93,16 +92,33 @@ class Lion: @property def time(self): """ - Amount of time the user has spent studying, accounting for pending values. + Amount of time the user has spent studying, accounting for a current session. """ - return int(self.data.tracked_time + self._pending_time) + # Base time from cached member data + time = self.data.tracked_time + + # Add current session time if it exists + if session := self.session: + time += session.duration + + return int(time) @property def coins(self): """ - Number of coins the user has, accounting for the pending value. + Number of coins the user has, accounting for the pending value and current session. """ - return int(self.data.coins + self._pending_coins) + # Base coin amount from cached member data + coins = self.data.coins + + # Add pending coin amount + coins += self._pending_coins + + # Add current session coins if applicable + if session := self.session: + coins += session.coins_earned + + return int(coins) @property def session(self): @@ -176,15 +192,6 @@ class Lion: if flush: self.flush() - def addTime(self, amount, flush=True): - """ - Add time to a user (in seconds), optionally storing the transaction in pending. - """ - self._pending_time += amount - self._pending[self.key] = self - if flush: - self.flush() - def flush(self): """ Flush any pending transactions to the database. @@ -202,7 +209,7 @@ class Lion: if lions: # Build userid to pending coin map pending = [ - (lion.guildid, lion.userid, int(lion._pending_coins), int(lion._pending_time)) + (lion.guildid, lion.userid, int(lion._pending_coins)) for lion in lions ] @@ -212,5 +219,4 @@ class Lion: # Cleanup pending users for lion in lions: lion._pending_coins -= int(lion._pending_coins) - lion._pending_time -= int(lion._pending_time) cls._pending.pop(lion.key, None) diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 24ffde49..24187f17 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -128,8 +128,31 @@ class Session: @property def data(self): + """ + Row of the `current_sessions` table corresponding to this session. + """ return current_sessions.fetch(self.key) + @property + def duration(self): + """ + Current duration of the session. + """ + return (utc_now() - self.data.start_time).total_seconds() + + @property + def coins_earned(self): + """ + Number of coins earned so far. + """ + data = self.data + + coins = self.duration * data.hourly_coins + coins += data.live_duration * data.hourly_live_coins + if data.live_start: + coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins + return coins // 3600 + def activate(self): """ Activate the study session. From bb181d90395f3168f1c5351fa94d2dd18f76534b Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 4 Dec 2021 11:13:26 +0200 Subject: [PATCH 19/56] (badges): Update to account for session tracker. Include current sessions in new badge scans. Remove the voice update badge hook. Account for current session in the current badge view. --- bot/modules/study/badges/badge_tracker.py | 18 +++--------------- data/migration/v5-v6/migration.sql | 2 +- data/schema.sql | 2 +- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/bot/modules/study/badges/badge_tracker.py b/bot/modules/study/badges/badge_tracker.py index 34e97c3a..2c0d33fb 100644 --- a/bot/modules/study/badges/badge_tracker.py +++ b/bot/modules/study/badges/badge_tracker.py @@ -8,7 +8,6 @@ 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 @@ -56,7 +55,8 @@ async def update_study_badges(full=False): # 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) + _timestamp=GEQ(client.appdata.last_study_badge_scan or 0), + _extra="OR session_start IS NOT NULL" ) else: update_rows = new_study_badges.select_where() @@ -287,7 +287,6 @@ async def study_badge_tracker(): """ while True: try: - Lion.sync() await update_study_badges() except Exception: # Unknown exception. Catch it so the loop doesn't die. @@ -304,11 +303,10 @@ async def study_badge_tracker(): await asyncio.sleep(60) -async def _update_member_studybadge(member): +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 @@ -332,16 +330,6 @@ async def _update_member_studybadge(member): 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/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 3e64007a..99fcefbf 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -125,7 +125,7 @@ CREATE VIEW current_study_badges AS *, (SELECT r.badgeid FROM study_badges r - WHERE r.guildid = members_totals.guildid AND members_totals.tracked_time > r.required_time + WHERE r.guildid = members_totals.guildid AND members_totals.total_tracked_time > r.required_time ORDER BY r.required_time DESC LIMIT 1) AS current_study_badgeid FROM members_totals; diff --git a/data/schema.sql b/data/schema.sql index 9f821f01..230f462c 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -563,7 +563,7 @@ CREATE VIEW current_study_badges AS *, (SELECT r.badgeid FROM study_badges r - WHERE r.guildid = members_totals.guildid AND members_totals.tracked_time > r.required_time + WHERE r.guildid = members_totals.guildid AND members_totals.total_tracked_time > r.required_time ORDER BY r.required_time DESC LIMIT 1) AS current_study_badgeid FROM members_totals; From 179b6ebf4ea11830699f9866fb69f0fda760ed58 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 4 Dec 2021 11:36:52 +0200 Subject: [PATCH 20/56] (leaderboards): Update to support sessions. Use `member_totals` to generate leaderboards instead of `members`. Fix typo in data coin conversion. --- bot/modules/economy/cointop_cmd.py | 20 ++++++++------------ bot/modules/study/top_cmd.py | 24 +++++++++--------------- bot/modules/study/tracking/data.py | 3 +++ data/migration/v5-v6/migration.sql | 4 ++-- data/schema.sql | 4 ++-- 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/bot/modules/economy/cointop_cmd.py b/bot/modules/economy/cointop_cmd.py index cd90b537..9d1b9b2d 100644 --- a/bot/modules/economy/cointop_cmd.py +++ b/bot/modules/economy/cointop_cmd.py @@ -46,19 +46,15 @@ async def cmd_topcoin(ctx): exclude.update(ctx.client.objects['blacklisted_users']) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + args = { + 'guildid': ctx.guild.id, + 'select_columns': ('userid', 'total_coins::INTEGER'), + '_extra': "AND total_coins > 0 ORDER BY total_coins DESC " + ("LIMIT 100" if top100 else "") + } if exclude: - user_data = tables.lions.select_where( - guildid=ctx.guild.id, - userid=data.NOT(list(exclude)), - select_columns=('userid', 'coins'), - _extra="AND coins > 0 ORDER BY coins DESC " + ("LIMIT 100" if top100 else "") - ) - else: - user_data = tables.lions.select_where( - guildid=ctx.guild.id, - select_columns=('userid', 'coins'), - _extra="AND coins > 0 ORDER BY coins DESC " + ("LIMIT 100" if top100 else "") - ) + args['userid'] = data.NOT(list(exclude)) + + user_data = tables.members_totals.select_where(**args) # Quit early if the leaderboard is empty if not user_data: diff --git a/bot/modules/study/top_cmd.py b/bot/modules/study/top_cmd.py index 774f0409..cb737ed8 100644 --- a/bot/modules/study/top_cmd.py +++ b/bot/modules/study/top_cmd.py @@ -38,27 +38,20 @@ async def cmd_top(ctx): ) top100 = (ctx.args == "100" or ctx.alias == "top100") - # Flush any pending coin transactions - Lion.sync() - # Fetch the leaderboard exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) exclude.update(ctx.client.objects['blacklisted_users']) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + args = { + 'guildid': ctx.guild.id, + 'select_columns': ('userid', 'total_tracked_time::INTEGER'), + '_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "") + } if exclude: - user_data = tables.lions.select_where( - guildid=ctx.guild.id, - userid=data.NOT(list(exclude)), - select_columns=('userid', 'tracked_time'), - _extra="AND tracked_time > 0 ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "") - ) - else: - user_data = tables.lions.select_where( - guildid=ctx.guild.id, - select_columns=('userid', 'tracked_time'), - _extra="AND tracked_time > 0 ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "") - ) + args['userid'] = data.NOT(list(exclude)) + + user_data = tables.members_totals.select_where(**args) # Quit early if the leaderboard is empty if not user_data: @@ -68,6 +61,7 @@ async def cmd_top(ctx): author_index = None entries = [] for i, (userid, time) in enumerate(user_data): + time = int(time) member = ctx.guild.get_member(userid) name = member.display_name if member else str(userid) name = name.replace('*', ' ').replace('_', ' ') diff --git a/bot/modules/study/tracking/data.py b/bot/modules/study/tracking/data.py index 5940bf63..d9dcae38 100644 --- a/bot/modules/study/tracking/data.py +++ b/bot/modules/study/tracking/data.py @@ -57,3 +57,6 @@ def study_time_since(guildid, userid, timestamp): cursor.callproc('study_time_since', (guildid, userid, timestamp)) rows = cursor.fetchall() return (rows[0][0] if rows else None) or 0 + + +members_totals = Table('members_totals') diff --git a/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 99fcefbf..0bf8f610 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -77,7 +77,7 @@ AS $$ ) SELECT guildid, userid, channelid, channel_type, start_time, total_duration, total_stream_duration, total_video_duration, total_live_duration, - (total_duration * hourly_coins + live_duration * hourly_live_coins) / 60 + (total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600 FROM current_sesh RETURNING * ) @@ -108,7 +108,7 @@ CREATE VIEW members_totals AS *, sesh.start_time AS session_start, tracked_time + COALESCE(sesh.total_duration, 0) AS total_tracked_time, - coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 60, 0) AS total_coins + coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 3600, 0) AS total_coins FROM members LEFT JOIN current_sessions_totals sesh USING (guildid, userid); diff --git a/data/schema.sql b/data/schema.sql index 230f462c..1e44151a 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -514,7 +514,7 @@ AS $$ ) SELECT guildid, userid, channelid, channel_type, start_time, total_duration, total_stream_duration, total_video_duration, total_live_duration, - (total_duration * hourly_coins + live_duration * hourly_live_coins) / 60 + (total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600 FROM current_sesh RETURNING * ) @@ -544,7 +544,7 @@ CREATE VIEW members_totals AS *, sesh.start_time AS session_start, tracked_time + COALESCE(sesh.total_duration, 0) AS total_tracked_time, - coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 60, 0) AS total_coins + coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 3600, 0) AS total_coins FROM members LEFT JOIN current_sessions_totals sesh USING (guildid, userid); From d529daaa27e7f3d7b4e712bee26215f60638a122 Mon Sep 17 00:00:00 2001 From: Harsha Raghu Date: Mon, 6 Dec 2021 19:14:47 +0530 Subject: [PATCH 21/56] [Guild_Admin] set_coins Admin command --- bot/modules/guild_admin/__init__.py | 1 + bot/modules/guild_admin/economy/__init__.py | 3 + bot/modules/guild_admin/economy/set_coins.py | 104 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 bot/modules/guild_admin/economy/__init__.py create mode 100644 bot/modules/guild_admin/economy/set_coins.py diff --git a/bot/modules/guild_admin/__init__.py b/bot/modules/guild_admin/__init__.py index 74d5644d..2c4f3922 100644 --- a/bot/modules/guild_admin/__init__.py +++ b/bot/modules/guild_admin/__init__.py @@ -4,3 +4,4 @@ from . import guild_config from . import statreset from . import new_members from . import reaction_roles +from . import economy diff --git a/bot/modules/guild_admin/economy/__init__.py b/bot/modules/guild_admin/economy/__init__.py new file mode 100644 index 00000000..2714bcdb --- /dev/null +++ b/bot/modules/guild_admin/economy/__init__.py @@ -0,0 +1,3 @@ +from ..module import module + +from . import set_coins \ No newline at end of file diff --git a/bot/modules/guild_admin/economy/set_coins.py b/bot/modules/guild_admin/economy/set_coins.py new file mode 100644 index 00000000..d74651a9 --- /dev/null +++ b/bot/modules/guild_admin/economy/set_coins.py @@ -0,0 +1,104 @@ +import discord +import datetime +from cmdClient.checks import in_guild + +from settings import GuildSettings +from core import Lion + +from ..module import module + +POSTGRES_INT_MAX = 2147483647 + +@module.cmd( + "set_coins", + group="Guild Admin", + desc="Set coins on a member." +) +@in_guild() +async def cmd_set(ctx): + """ + Usage``: + {prefix}set_coins + Description: + Sets the given number of coins on the mentioned user. + If a number greater than 0 is mentioned, will add coins. + If a number less than 0 is mentioned, will remove coins. + Note: LionCoins on a member cannot be negative. + Example: + {prefix}set_coins {ctx.author.mention} 100 + {prefix}set_coins {ctx.author.mention} -100 + """ + # Extract target and amount + # Handle a slightly more flexible input than stated + splits = ctx.args.split() + digits = [isNumber(split) for split in splits[:2]] + mentions = ctx.msg.mentions + if len(splits) < 2 or not any(digits) or not (all(digits) or mentions): + return await _send_usage(ctx) + + if all(digits): + # Both are digits, hopefully one is a member id, and one is an amount. + target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1]) + if not target: + amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1])) + if not target: + return await _send_usage(ctx) + elif digits[0]: + amount, target = int(splits[0]), mentions[0] + elif digits[1]: + target, amount = mentions[0], int(splits[1]) + + # Fetch the associated lion + target_lion = Lion.fetch(ctx.guild.id, target.id) + + # Check sanity conditions + if target == ctx.client.user: + return await ctx.embed_reply("Thanks, but Ari looks after all my needs!") + if target.bot: + return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention)) + + # Finally, send the amount and the ack message + # Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range + target_coins_to_set = target_lion.coins + amount + if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX: + target_lion.addCoins(amount) + elif target_coins_to_set < 0: + target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0 + target_lion.addCoins(target_coins_to_set) + target_coins_to_set = 0 + else: + return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX)) + + embed = discord.Embed( + title="Funds Set", + description="You have set LionCoins on {} to **{}**!".format(target.mention,target_coins_to_set), + colour=discord.Colour.orange(), + timestamp=datetime.datetime.utcnow() + ).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url) + + await ctx.reply(embed=embed, reference=ctx.msg) + GuildSettings(ctx.guild.id).event_log.log( + "{} set {}'s LionCoins to`{}`.".format( + ctx.author.mention, + target.mention, + target_coins_to_set + ), + title="Funds Set" + ) + +def isNumber(var): + try: + return isinstance(int(var), int) + except: + return False + +async def _send_usage(ctx): + return await ctx.error_reply( + "**Usage:** `{prefix}set_coins `\n" + "**Example:**\n" + " {prefix}set_coins {ctx.author.mention} 100\n" + " {prefix}set_coins {ctx.author.mention} -100".format( + prefix=ctx.best_prefix, + ctx=ctx + ) + ) From 4582d9a1a10391cc1be4381fbfb204d9d1bdc0d8 Mon Sep 17 00:00:00 2001 From: Harsha Raghu Date: Mon, 6 Dec 2021 19:50:28 +0530 Subject: [PATCH 22/56] Implement proper check for admin --- bot/modules/guild_admin/economy/set_coins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/modules/guild_admin/economy/set_coins.py b/bot/modules/guild_admin/economy/set_coins.py index d74651a9..c0744a13 100644 --- a/bot/modules/guild_admin/economy/set_coins.py +++ b/bot/modules/guild_admin/economy/set_coins.py @@ -1,6 +1,6 @@ import discord import datetime -from cmdClient.checks import in_guild +from wards import guild_admin from settings import GuildSettings from core import Lion @@ -14,7 +14,7 @@ POSTGRES_INT_MAX = 2147483647 group="Guild Admin", desc="Set coins on a member." ) -@in_guild() +@guild_admin() async def cmd_set(ctx): """ Usage``: From 971446ffb590092e9785481c5e53eed996ee6d77 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 6 Dec 2021 20:48:28 +0200 Subject: [PATCH 23/56] (stats): Rewrite to include session data. Complete `stats` command rewrite to include session data. Added `get_member_rank` query to get accurate time and coin ranks. --- bot/core/data.py | 31 +++- bot/modules/study/stats_cmd.py | 266 ++++++++++++++++++++++++++++----- 2 files changed, 255 insertions(+), 42 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index 013339b8..eca2f4e6 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -50,8 +50,6 @@ lions = RowTable( attach_as='lions' ) -lion_ranks = Table('member_ranks', attach_as='lion_ranks') - @lions.save_query def add_pending(pending): @@ -83,6 +81,35 @@ def add_pending(pending): return lions._make_rows(*data) +lion_ranks = Table('member_ranks', attach_as='lion_ranks') + + +@lions.save_query +def get_member_rank(guildid, userid, untracked): + """ + Get the time and coin ranking for the given member, ignoring the provided untracked members. + """ + with lions.conn as conn: + with conn.cursor() as curs: + curs.execute( + """ + SELECT + time_rank, coin_rank + FROM ( + SELECT + userid, + row_number() OVER (ORDER BY total_tracked_time DESC, userid ASC) AS time_rank, + row_number() OVER (ORDER BY total_coins DESC, userid ASC) AS coin_rank + FROM members_totals + WHERE + guildid=%s AND userid NOT IN %s + ) AS guild_ranks WHERE userid=%s + """, + (guildid, tuple(untracked), userid) + ) + return curs.fetchone() or (None, None) + + global_guild_blacklist = Table('global_guild_blacklist') global_user_blacklist = Table('global_user_blacklist') ignored_members = Table('ignored_members') diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index 90768202..9b9329dc 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -1,18 +1,22 @@ -import datetime +from datetime import datetime, timedelta import discord from cmdClient.checks import in_guild -from utils.lib import strfdur +from utils.lib import strfdur, prop_tabulate, utc_now from data import tables +from data.conditions import LEQ from core import Lion +from .tracking.data import study_time_since, session_history + from .module import module @module.cmd( "stats", group="Statistics", - desc="View a summary of your study statistics!" + desc="View a summary of your study statistics!", + aliases=('profile',) ) @in_guild() async def cmd_stats(ctx): @@ -23,6 +27,7 @@ async def cmd_stats(ctx): Description: View the study statistics for yourself or the mentioned user. """ + # Identify the target if ctx.args: if not ctx.msg.mentions: return await ctx.error_reply("Please mention a user to view their statistics!") @@ -30,54 +35,235 @@ async def cmd_stats(ctx): else: target = ctx.author - # Collect the required target data + # System sync + Lion.sync() + + # Fetch the required data lion = Lion.fetch(ctx.guild.id, target.id) - rank_data = tables.lion_ranks.select_one_where( + + history = session_history.select_where( + guildid=ctx.guild.id, userid=target.id, - guildid=ctx.guild.id + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time DESC" ) - # Extract and format data - time = strfdur(lion.time) + # Current economy balance (accounting for current session) 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) + season_time = lion.time + workout_total = lion.data.workout_count + + # Leaderboard ranks + exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) + exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + if target.id in exclude: + time_rank = None + coin_rank = None else: - league = "No league yet!" + time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0])) - time_lb_pos = rank_data['time_rank'] - coin_lb_pos = rank_data['coin_rank'] + # Study time + # First get the all/month/week/day timestamps + day_start = lion.day_start + period_timestamps = ( + datetime(1970, 1, 1), + day_start.replace(day=1), + day_start - timedelta(days=day_start.weekday()), + day_start + ) + study_times = [0, 0, 0, 0] + for i, timestamp in enumerate(period_timestamps): + study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp) + if not study_time: + # So we don't make unecessary database calls + break + study_times[i] = study_time - # Build embed + # Streak statistics + streak = 0 + current_streak = None + max_streak = 0 + + day_attended = True if 'sessions' in ctx.client.objects and lion.session else None + date = day_start + daydiff = timedelta(days=1) + + periods = [(row['start_time'], row['end_time']) for row in history] + + i = 0 + while i < len(periods): + row = periods[i] + i += 1 + if row[1] > date: + # They attended this day + day_attended = True + continue + elif day_attended is None: + # Didn't attend today, but don't break streak + day_attended = False + date -= daydiff + i -= 1 + continue + elif not day_attended: + # Didn't attend the day, streak broken + date -= daydiff + i -= 1 + pass + else: + # Attended the day + streak += 1 + + # Move window to the previous day and try the row again + day_attended = False + prev_date = date + date -= daydiff + i -= 1 + + # Special case, when the last session started in the previous day + # Then the day is already attended + if i > 1 and date < periods[i-2][0] <= prev_date: + day_attended = True + + continue + + max_streak = max(max_streak, streak) + if current_streak is None: + current_streak = streak + streak = 0 + + # Handle loop exit state, i.e. the last streak + if day_attended: + streak += 1 + max_streak = max(max_streak, streak) + if current_streak is None: + current_streak = streak + + # Accountability stats + accountability = tables.accountability_member_info.select_where( + userid=target.id, + start_at=LEQ(utc_now()), + select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), + _extra="ORDER BY start_at DESC" + ) + if len(accountability): + acc_duration = sum(row['duration'] for row in accountability) + + acc_attended = sum(row['attended'] for row in accountability) + acc_total = len(accountability) + acc_rate = (acc_attended * 100) / acc_total + else: + acc_duration = 0 + acc_rate = 0 + + # Study League + guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id) + if lion.data.last_study_badgeid: + current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid) + else: + current_badge = None + + next_badge = min( + (badge for badge in guild_badges + if badge.required_time > (current_badge.required_time if current_badge else 0)), + key=lambda badge: badge.required_time, + default=None + ) + + # We have all the data + # Now start building the 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 + colour=discord.Colour.orange(), + title="Study Profile for {}".format(str(target)) ) - embed.add_field( - name="🦁 Revision League", - value=league + embed.set_thumbnail(url=target.avatar_url) + + # Add studying since if they have studied + if history: + embed.set_footer(text="Studying Since") + embed.timestamp = history[-1]['start_time'] + + # Set the description based on season time and server rank + if season_time: + time_str = "**{}:{}**".format( + season_time // 3600, + (season_time // 60) % 60 + ) + if time_rank is None: + rank_str = None + elif time_rank == 1: + rank_str = "1st" + elif time_rank == 2: + rank_str = "2nd" + elif time_rank == 3: + rank_str = "3rd" + else: + time_rank = "{}th".format(time_rank) + + embed.description = "{} has studied for **{}**{}{}".format( + target.mention, + time_str, + " this season" if study_times[0] - season_time > 60 else "", + ", and is ranked **{}** in the server!".format(rank_str) if rank_str else "." + ) + else: + embed.description = "{} hasn't studied in this server yet!".format(target.mention) + + # Build the stats table + stats = {} + + stats['Coins Earned'] = "**{}** LC".format( + coins, + # "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked" ) - embed.add_field( - name="🦁 LionCoins", - value=coins + if workout_total: + stats['Workouts'] = "**{}** sessions".format(workout_total) + if acc_duration: + stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format( + acc_duration // 3600, + acc_rate + ) + stats['Study Streak'] = "**{}** days{}".format( + streak, + " (longest **{}** days)".format(max_streak) if max_streak else '' ) - 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" + + stats_table = prop_tabulate(*zip(*stats.items())) + + # Build the time table + time_table = prop_tabulate( + ('Daily', 'Weekly', 'Monthly', 'All Time'), + ["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)] ) + + # The order they are added depends on the size of the stats table + if len(stats) >= 4: + embed.add_field(name="Statistics", value=stats_table) + embed.add_field(name="Study Time", value=time_table) + else: + embed.add_field(name="Study Time", value=time_table) + embed.add_field(name="Statistics", value=stats_table) + + # Add the study league field + if current_badge or next_badge: + current_str = ( + "You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "Not league yet!" + ) + if next_badge: + needed = max(next_badge.required_time - season_time, 0) + next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format( + needed // 3600, + (needed // 60) % 60, + next_badge.roleid + ) + else: + next_str = "You have reached the highest league! Congratulations!" + embed.add_field( + name="Study League", + value="{}\n{}".format(current_str, next_str), + inline=False + ) await ctx.reply(embed=embed) From d95faed02bc5453362d35bc72cc56f32340ecdb2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 7 Dec 2021 11:02:40 +0200 Subject: [PATCH 24/56] fix (stats): Repair some formatting issues. --- bot/modules/study/stats_cmd.py | 10 +++++----- bot/modules/study/top_cmd.py | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index 9b9329dc..dbd71302 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -2,12 +2,12 @@ from datetime import datetime, timedelta import discord from cmdClient.checks import in_guild -from utils.lib import strfdur, prop_tabulate, utc_now +from utils.lib import prop_tabulate, utc_now from data import tables from data.conditions import LEQ from core import Lion -from .tracking.data import study_time_since, session_history +from .tracking.data import session_history from .module import module @@ -15,7 +15,7 @@ from .module import module @module.cmd( "stats", group="Statistics", - desc="View a summary of your study statistics!", + desc="View your personal server study statistics!", aliases=('profile',) ) @in_guild() @@ -227,7 +227,7 @@ async def cmd_stats(ctx): acc_rate ) stats['Study Streak'] = "**{}** days{}".format( - streak, + current_streak, " (longest **{}** days)".format(max_streak) if max_streak else '' ) @@ -250,7 +250,7 @@ async def cmd_stats(ctx): # Add the study league field if current_badge or next_badge: current_str = ( - "You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "Not league yet!" + "You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!" ) if next_badge: needed = max(next_badge.required_time - season_time, 0) diff --git a/bot/modules/study/top_cmd.py b/bot/modules/study/top_cmd.py index cb737ed8..cb4008f7 100644 --- a/bot/modules/study/top_cmd.py +++ b/bot/modules/study/top_cmd.py @@ -61,7 +61,6 @@ async def cmd_top(ctx): author_index = None entries = [] for i, (userid, time) in enumerate(user_data): - time = int(time) member = ctx.guild.get_member(userid) name = member.display_name if member else str(userid) name = name.replace('*', ' ').replace('_', ' ') From b273ae05969eadca9c1462e55a8d70de6841df8f Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 7 Dec 2021 13:23:13 +0200 Subject: [PATCH 25/56] (data): Update version and migration. --- bot/constants.py | 2 +- data/migration/v5-v6/migration.sql | 16 +++++++++------- data/schema.sql | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 132ce93d..e5eb789a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 5 +DATA_VERSION = 6 diff --git a/data/migration/v5-v6/migration.sql b/data/migration/v5-v6/migration.sql index 0bf8f610..eae1f88b 100644 --- a/data/migration/v5-v6/migration.sql +++ b/data/migration/v5-v6/migration.sql @@ -1,16 +1,16 @@ -DROP TYPE IF EXISTS SessionChannelType CASCADE; -DROP TABLE IF EXISTS session_history CASCADE; -DROP TABLE IF EXISTS current_sessions CASCADE; -DROP FUNCTION IF EXISTS close_study_session; +-- DROP TYPE IF EXISTS SessionChannelType CASCADE; +-- DROP TABLE IF EXISTS session_history CASCADE; +-- DROP TABLE IF EXISTS current_sessions CASCADE; +-- DROP FUNCTION IF EXISTS close_study_session(_guildid BIGINT, _userid BIGINT); +-- DROP FUNCTION IF EXISTS study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + +-- DROP VIEW IF EXISTS current_sessions_totals CASCADE; -DROP VIEW IF EXISTS current_sessions_totals CASCADE; DROP VIEW IF EXISTS member_totals CASCADE; DROP VIEW IF EXISTS member_ranks CASCADE; DROP VIEW IF EXISTS current_study_badges CASCADE; DROP VIEW IF EXISTS new_study_badges CASCADE; -DROP FUNCTION IF EXISTS study_time_since; - CREATE TYPE SessionChannelType AS ENUM ( 'STANDARD', @@ -176,3 +176,5 @@ AS $$ $$ LANGUAGE PLPGSQL; ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER; + +INSERT INTO VersionHistory (version, author) VALUES (6, 'v5-v6 Migration'); diff --git a/data/schema.sql b/data/schema.sql index 1e44151a..a7a4af31 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (5, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (6, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() From 9d0fd93822192d14626616ab100740ee80699317 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 8 Dec 2021 11:44:15 +0200 Subject: [PATCH 26/56] fix (stats): Fix time formatting issue. --- bot/modules/study/stats_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index dbd71302..67ecfa06 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -188,7 +188,7 @@ async def cmd_stats(ctx): # Set the description based on season time and server rank if season_time: - time_str = "**{}:{}**".format( + time_str = "**{}:{:02}**".format( season_time // 3600, (season_time // 60) % 60 ) From f18af33fb600c4fcd79ad8190877225f4ff2d1d1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 8 Dec 2021 11:44:35 +0200 Subject: [PATCH 27/56] fix (sessions): Recognise accountability sessions. --- bot/modules/study/tracking/session_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 24187f17..96262f04 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -78,7 +78,7 @@ class Session: # TODO: More reliable channel type determination if state.channel.id in tables.rented.row_cache: channel_type = SessionChannelType.RENTED - elif state.channel.id in tables.accountability_rooms.row_cache: + elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data: channel_type = SessionChannelType.ACCOUNTABILITY else: channel_type = SessionChannelType.STANDARD From e8547f9f4bb2278f3abbef53868c9d8ff1a9d444 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 15 Dec 2021 14:36:09 +0200 Subject: [PATCH 28/56] fix (stats): Fix misnamed variable. --- bot/modules/study/stats_cmd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index ef3684e2..8e4bde59 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -202,7 +202,7 @@ async def cmd_stats(ctx): elif time_rank == 3: rank_str = "3rd" else: - time_rank = "{}th".format(time_rank) + rank_str = "{}th".format(time_rank) embed.description = "{} has studied for **{}**{}{}".format( target.mention, From 73e329ee948faac712bbc827898eadb494d500f0 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 08:14:32 +0200 Subject: [PATCH 29/56] fix (LionModule): Fix channel existence ward. Fixes an issue where all commands would fail in DMs. --- bot/LionModule.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/LionModule.py b/bot/LionModule.py index c1fd7256..6e89cf5e 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -81,15 +81,15 @@ class LionModule(Module): pass raise SafeCancellation(details="Module '{}' is not ready.".format(self.name)) - # Check that the channel and guild still exists - if not ctx.client.get_guild(ctx.guild.id) or not ctx.guild.get_channel(ctx.ch.id): - raise SafeCancellation(details='Command channel is no longer reachable.') - # Check global user blacklist if ctx.author.id in ctx.client.objects['blacklisted_users']: raise SafeCancellation(details='User is blacklisted.') if ctx.guild: + # Check that the channel and guild still exists + if not ctx.client.get_guild(ctx.guild.id) or not ctx.guild.get_channel(ctx.ch.id): + raise SafeCancellation(details='Command channel is no longer reachable.') + # Check global guild blacklist if ctx.guild.id in ctx.client.objects['blacklisted_guilds']: raise SafeCancellation(details='Guild is blacklisted.') From 8208f601d6c603700199e84cfe67d24b557594cb Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 08:18:40 +0200 Subject: [PATCH 30/56] (stats): Remove dynamic field-reordering. --- bot/modules/study/stats_cmd.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index 8e4bde59..29e412e6 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -240,13 +240,9 @@ async def cmd_stats(ctx): ["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)] ) - # The order they are added depends on the size of the stats table - if len(stats) >= 4: - embed.add_field(name="Statistics", value=stats_table) - embed.add_field(name="Study Time", value=time_table) - else: - embed.add_field(name="Study Time", value=time_table) - embed.add_field(name="Statistics", value=stats_table) + # Populate the embed + embed.add_field(name="Study Time", value=time_table) + embed.add_field(name="Statistics", value=stats_table) # Add the study league field if current_badge or next_badge: From d2fd3c9c0d2673d0ad34dcb613790b8f329a931d Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 08:34:38 +0200 Subject: [PATCH 31/56] fix (renting): Disallow removing owner. Don't allow the room owner to be added or removed from the rented room. Also fixes an issue where the room expiry log would try to use deleted data. --- bot/modules/renting/commands.py | 8 ++++++-- bot/modules/renting/rooms.py | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/modules/renting/commands.py b/bot/modules/renting/commands.py index b94c01f5..0683d90f 100644 --- a/bot/modules/renting/commands.py +++ b/bot/modules/renting/commands.py @@ -54,9 +54,13 @@ async def cmd_rent(ctx): # Extract members to remove current_memberids = set(room.memberids) + if ctx.author in ctx.msg.mentions: + return await ctx.error_reply( + "You can't remove yourself from your own room!" + ) to_remove = ( member for member in ctx.msg.mentions - if member.id in current_memberids + if member.id in current_memberids and member.id != ctx.author.id ) to_remove = list(set(to_remove)) # Remove duplicates @@ -86,7 +90,7 @@ async def cmd_rent(ctx): current_memberids = set(room.memberids) to_add = ( member for member in ctx.msg.mentions - if member.id not in current_memberids and member.id != ctx.author + if member.id not in current_memberids and member.id != ctx.author.id ) to_add = list(set(to_add)) # Remove duplicates diff --git a/bot/modules/renting/rooms.py b/bot/modules/renting/rooms.py index 9e79d5b0..3e1d19c4 100644 --- a/bot/modules/renting/rooms.py +++ b/bot/modules/renting/rooms.py @@ -187,14 +187,14 @@ class Room: except discord.HTTPException: pass - # Delete the room from data (cascades to member deletion) - self.delete() - guild_settings.event_log.log( title="Private study room expired!", description="<@{}>'s private study room expired.".format(self.data.ownerid) ) + # Delete the room from data (cascades to member deletion) + self.delete() + async def add_members(self, *members): guild_settings = GuildSettings(self.data.guildid) From f3cf4fcb5a24ba1f85646e38d001a893ef28d84d Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 08:57:57 +0200 Subject: [PATCH 32/56] fix (accountability): Disallow near bookings. Don't allow members to book within 11 minutes of the room start. Patches a race condition where the open room doesn't include the member. --- bot/modules/accountability/commands.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/modules/accountability/commands.py b/bot/modules/accountability/commands.py index 6180d4bd..a0d19509 100644 --- a/bot/modules/accountability/commands.py +++ b/bot/modules/accountability/commands.py @@ -39,6 +39,7 @@ def time_format(time): time.timestamp() + 3600, ) + user_locks = {} # Map userid -> ctx @@ -229,7 +230,10 @@ async def cmd_rooms(ctx): start_time + datetime.timedelta(hours=n) for n in range(1, 25) ) - times = [time for time in times if time not in already_joined_times] + times = [ + time for time in times + if time not in already_joined_times and (time - utc_now()).total_seconds() > 660 + ] lines = [ "`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format( num=i, @@ -255,7 +259,7 @@ async def cmd_rooms(ctx): await ctx.cancellable( out_msg, cancel_message="Booking menu cancelled, no sessions were booked.", - timeout=70 + timeout=60 ) def check(msg): @@ -265,7 +269,7 @@ async def cmd_rooms(ctx): with ensure_exclusive(ctx): try: - message = await ctx.client.wait_for('message', check=check, timeout=60) + message = await ctx.client.wait_for('message', check=check, timeout=30) except asyncio.TimeoutError: try: await out_msg.edit( @@ -325,6 +329,7 @@ async def cmd_rooms(ctx): ) # Handle case where the slot has already opened + # TODO: Fix this, doesn't always work aguild = AGuild.cache.get(ctx.guild.id, None) if aguild: if aguild.upcoming_slot and aguild.upcoming_slot.start_time in to_book: From d498673020e7144ed59865ab1746d97307c1c529 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 10:34:34 +0200 Subject: [PATCH 33/56] sharding (data): Add `SHARDID` condition. --- bot/data/conditions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/data/conditions.py b/bot/data/conditions.py index 4687a929..fdd0739f 100644 --- a/bot/data/conditions.py +++ b/bot/data/conditions.py @@ -70,5 +70,17 @@ class Constant(Condition): conditions.append("{} {}".format(key, self.value)) +class SHARDID(Condition): + __slots__ = ('shardid', 'shard_count') + + def __init__(self, shardid, shard_count): + self.shardid = shardid + self.shard_count = shard_count + + def apply(self, key, values, conditions): + conditions.append("({} >> 22) %% {} = {}".format(key, self.shard_count, _replace_char)) + values.append(self.shardid) + + NULL = Constant('IS NULL') NOTNULL = Constant('IS NOT NULL') From 20697c48231eb5f94fd4bdf453d5f67fe8b71a30 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 11:28:43 +0200 Subject: [PATCH 34/56] sharding (core): Add base sharding support. Add `meta.args` for command line argument access. Add command line argument support for shard number. Add shard count to config file. Add `meta.sharding` exposing shard properties. Add shard number to logging methods. Add shard number to data appid. --- bot/data/__init__.py | 2 +- bot/data/conditions.py | 5 +++++ bot/main.py | 9 +++++++-- bot/meta/__init__.py | 4 +++- bot/meta/args.py | 19 +++++++++++++++++++ bot/meta/client.py | 15 +++++++++------ bot/meta/config.py | 8 ++++---- bot/meta/logger.py | 15 +++++++++++++-- bot/meta/sharding.py | 9 +++++++++ config/example-bot.conf | 3 +++ 10 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 bot/meta/args.py create mode 100644 bot/meta/sharding.py diff --git a/bot/data/__init__.py b/bot/data/__init__.py index f048ce37..2deecc48 100644 --- a/bot/data/__init__.py +++ b/bot/data/__init__.py @@ -1,5 +1,5 @@ +from .conditions import Condition, NOT, Constant, NULL, NOTNULL # noqa from .connection import conn # noqa from .formatters import UpdateValue, UpdateValueAdd # noqa from .interfaces import Table, RowTable, Row, tables # noqa from .queries import insert, insert_many, select_where, update_where, upsert, delete_where # noqa -from .conditions import Condition, NOT, Constant, NULL, NOTNULL # noqa diff --git a/bot/data/conditions.py b/bot/data/conditions.py index fdd0739f..52999504 100644 --- a/bot/data/conditions.py +++ b/bot/data/conditions.py @@ -1,5 +1,7 @@ from .connection import _replace_char +from meta import sharding + class Condition: """ @@ -82,5 +84,8 @@ class SHARDID(Condition): values.append(self.shardid) +THIS_SHARD = SHARDID(sharding.shard_number, sharding.shard_count) + + NULL = Constant('IS NULL') NOTNULL = Constant('IS NOT NULL') diff --git a/bot/main.py b/bot/main.py index ac818e36..066bf86e 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,4 +1,4 @@ -from meta import client, conf, log +from meta import client, conf, log, sharding from data import tables @@ -7,7 +7,12 @@ import core # noqa import modules # noqa # Load and attach app specific data -client.appdata = core.data.meta.fetch_or_create(conf.bot['data_appid']) +if sharding.sharded: + appname = f"{conf.bot['data_appid']}_{sharding.shard_count}_{sharding.shard_number}" +else: + appname = conf.bot['data_appid'] +client.appdata = core.data.meta.fetch_or_create(appname) + client.data = tables # Initialise all modules diff --git a/bot/meta/__init__.py b/bot/meta/__init__.py index dd852d4f..eab9c7b8 100644 --- a/bot/meta/__init__.py +++ b/bot/meta/__init__.py @@ -1,3 +1,5 @@ +from .logger import log, logger from .client import client from .config import conf -from .logger import log, logger +from .args import args +from . import sharding diff --git a/bot/meta/args.py b/bot/meta/args.py new file mode 100644 index 00000000..c2dd70d6 --- /dev/null +++ b/bot/meta/args.py @@ -0,0 +1,19 @@ +import argparse + +from constants import CONFIG_FILE + +# ------------------------------ +# Parsed commandline arguments +# ------------------------------ +parser = argparse.ArgumentParser() +parser.add_argument('--conf', + dest='config', + default=CONFIG_FILE, + help="Path to configuration file.") +parser.add_argument('--shard', + dest='shard', + default=None, + type=int, + help="Shard number to run, if applicable.") + +args = parser.parse_args() diff --git a/bot/meta/client.py b/bot/meta/client.py index 5310171d..50414aa8 100644 --- a/bot/meta/client.py +++ b/bot/meta/client.py @@ -1,16 +1,19 @@ from discord import Intents from cmdClient.cmdClient import cmdClient -from .config import Conf +from .config import conf +from .sharding import shard_number, shard_count -from constants import CONFIG_FILE - -# Initialise config -conf = Conf(CONFIG_FILE) # Initialise client owners = [int(owner) for owner in conf.bot.getlist('owners')] intents = Intents.all() intents.presences = False -client = cmdClient(prefix=conf.bot['prefix'], owners=owners, intents=intents) +client = cmdClient( + prefix=conf.bot['prefix'], + owners=owners, + intents=intents, + shard_id=shard_number, + shard_count=shard_count +) client.conf = conf diff --git a/bot/meta/config.py b/bot/meta/config.py index a94d2b1a..ca779924 100644 --- a/bot/meta/config.py +++ b/bot/meta/config.py @@ -1,9 +1,6 @@ import configparser as cfgp - -conf = None # type: Conf - -CONF_FILE = "bot/bot.conf" +from .args import args class Conf: @@ -57,3 +54,6 @@ class Conf: def write(self): with open(self.configfile, 'w') as conffile: self.config.write(conffile) + + +conf = Conf(args.config) diff --git a/bot/meta/logger.py b/bot/meta/logger.py index 858b1292..3e7bd026 100644 --- a/bot/meta/logger.py +++ b/bot/meta/logger.py @@ -9,11 +9,18 @@ from utils.lib import mail, split_text from .client import client from .config import conf +from . import sharding # Setup the logger logger = logging.getLogger() -log_fmt = logging.Formatter(fmt='[{asctime}][{levelname:^8}] {message}', datefmt='%d/%m | %H:%M:%S', style='{') +log_fmt = logging.Formatter( + fmt=('[{asctime}][{levelname:^8}]' + + '[SHARD {}]'.format(sharding.shard_number) + + '{message}'), + datefmt='%d/%m | %H:%M:%S', + style='{' +) # term_handler = logging.StreamHandler(sys.stdout) # term_handler.setFormatter(log_fmt) # logger.addHandler(term_handler) @@ -77,7 +84,11 @@ async def live_log(message, context, level): log_chid = conf.bot.getint('log_channel') # Generate the log messages - header = "[{}][{}]".format(logging.getLevelName(level), str(context)) + if sharding.sharded: + header = f"[{logging.getLevelName(level)}][SHARD {sharding.shard_number}][{context}]" + else: + header = f"[{logging.getLevelName(level)}][{context}]" + if len(message) > 1900: blocks = split_text(message, blocksize=1900, code=False) else: diff --git a/bot/meta/sharding.py b/bot/meta/sharding.py new file mode 100644 index 00000000..ffe86a89 --- /dev/null +++ b/bot/meta/sharding.py @@ -0,0 +1,9 @@ +from .args import args +from .config import conf + + +shard_number = args.shard or 0 + +shard_count = conf.bot.getint('shard_count', 1) + +sharded = (shard_count > 0) diff --git a/config/example-bot.conf b/config/example-bot.conf index b2fc7a48..d2ec5dd1 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -1,6 +1,7 @@ [DEFAULT] log_file = bot.log log_channel = +error_channel = guild_log_channel = prefix = ! @@ -10,4 +11,6 @@ owners = 413668234269818890, 389399222400712714 database = dbname=lionbot data_appid = LionBot +shard_count = 1 + lion_sync_period = 60 From 1c05d7a88072f4a134217525a92590b86aa0ff96 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 13:07:20 +0200 Subject: [PATCH 35/56] sharding (blacklists): Blacklist shard support. Moved the `user_blacklist` and `guild_blacklist` to a client TTL cache. --- bot/LionModule.py | 4 +- bot/core/blacklists.py | 44 ++++++++----------- bot/modules/economy/cointop_cmd.py | 2 +- bot/modules/reminders/reminder.py | 2 +- bot/modules/study/stats_cmd.py | 2 +- bot/modules/study/top_cmd.py | 2 +- bot/modules/study/tracking/session_tracker.py | 2 +- bot/modules/study/tracking/time_tracker.py | 2 +- bot/modules/sysadmin/blacklist.py | 33 +++++++++----- bot/modules/workout/tracker.py | 2 +- 10 files changed, 48 insertions(+), 47 deletions(-) diff --git a/bot/LionModule.py b/bot/LionModule.py index 6e89cf5e..9c2a4671 100644 --- a/bot/LionModule.py +++ b/bot/LionModule.py @@ -82,7 +82,7 @@ class LionModule(Module): raise SafeCancellation(details="Module '{}' is not ready.".format(self.name)) # Check global user blacklist - if ctx.author.id in ctx.client.objects['blacklisted_users']: + if ctx.author.id in ctx.client.user_blacklist(): raise SafeCancellation(details='User is blacklisted.') if ctx.guild: @@ -91,7 +91,7 @@ class LionModule(Module): raise SafeCancellation(details='Command channel is no longer reachable.') # Check global guild blacklist - if ctx.guild.id in ctx.client.objects['blacklisted_guilds']: + if ctx.guild.id in ctx.client.guild_blacklist(): raise SafeCancellation(details='Guild is blacklisted.') # Check guild's own member blacklist diff --git a/bot/core/blacklists.py b/bot/core/blacklists.py index 942bd012..1ca5bd9c 100644 --- a/bot/core/blacklists.py +++ b/bot/core/blacklists.py @@ -1,9 +1,8 @@ """ Guild, user, and member blacklists. - -NOTE: The pre-loading methods are not shard-optimised. """ from collections import defaultdict +import cachetools.func from data import tables from meta import client @@ -11,32 +10,22 @@ from meta import client from .module import module -@module.init_task -def load_guild_blacklist(client): +@cachetools.func.ttl_cache(ttl=300) +def guild_blacklist(): """ - Load the blacklisted guilds. + Get the guild blacklist """ rows = tables.global_guild_blacklist.select_where() - client.objects['blacklisted_guilds'] = set(row['guildid'] for row in rows) - if rows: - client.log( - "Loaded {} blacklisted guilds.".format(len(rows)), - context="GUILD_BLACKLIST" - ) + return set(row['guildid'] for row in rows) -@module.init_task -def load_user_blacklist(client): +@cachetools.func.ttl_cache(ttl=300) +def user_blacklist(): """ - Load the blacklisted users. + Get the global user blacklist. """ rows = tables.global_user_blacklist.select_where() - client.objects['blacklisted_users'] = set(row['userid'] for row in rows) - if rows: - client.log( - "Loaded {} globally blacklisted users.".format(len(rows)), - context="USER_BLACKLIST" - ) + return set(row['userid'] for row in rows) @module.init_task @@ -62,18 +51,20 @@ def load_ignored_members(client): ) +@module.init_task +def attach_client_blacklists(client): + client.guild_blacklist = guild_blacklist + client.user_blacklist = user_blacklist + + @module.launch_task async def leave_blacklisted_guilds(client): """ Launch task to leave any blacklisted guilds we are in. - Assumes that the blacklisted guild list has been initialised. """ - # Cache to avoic repeated lookups - blacklisted = client.objects['blacklisted_guilds'] - to_leave = [ guild for guild in client.guilds - if guild.id in blacklisted + if guild.id in guild_blacklist() ] for guild in to_leave: @@ -92,7 +83,8 @@ async def check_guild_blacklist(client, guild): Guild join event handler to check whether the guild is blacklisted. If so, leaves the guild. """ - if guild.id in client.objects['blacklisted_guilds']: + # First refresh the blacklist cache + if guild.id in guild_blacklist(): await guild.leave() client.log( "Automatically left blacklisted guild '{}' (gid:{}) upon join.".format(guild.name, guild.id), diff --git a/bot/modules/economy/cointop_cmd.py b/bot/modules/economy/cointop_cmd.py index 9d1b9b2d..81bdbad9 100644 --- a/bot/modules/economy/cointop_cmd.py +++ b/bot/modules/economy/cointop_cmd.py @@ -43,7 +43,7 @@ async def cmd_topcoin(ctx): # Fetch the leaderboard exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.user_blacklist()) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) args = { diff --git a/bot/modules/reminders/reminder.py b/bot/modules/reminders/reminder.py index d3e4f764..73870341 100644 --- a/bot/modules/reminders/reminder.py +++ b/bot/modules/reminders/reminder.py @@ -134,7 +134,7 @@ class Reminder: """ Execute the reminder. """ - if self.data.userid in client.objects['blacklisted_users']: + if self.data.userid in client.user_blacklist(): self.delete(self.reminderid) return diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/study/stats_cmd.py index 29e412e6..88bc8be5 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/study/stats_cmd.py @@ -59,7 +59,7 @@ async def cmd_stats(ctx): # Leaderboard ranks exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.user_blacklist()) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) if target.id in exclude: time_rank = None diff --git a/bot/modules/study/top_cmd.py b/bot/modules/study/top_cmd.py index cb4008f7..79564c1f 100644 --- a/bot/modules/study/top_cmd.py +++ b/bot/modules/study/top_cmd.py @@ -40,7 +40,7 @@ async def cmd_top(ctx): # Fetch the leaderboard exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.objects['blacklisted_users']) + exclude.update(ctx.client.user_blacklist()) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) args = { diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 96262f04..9cbe53be 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -298,7 +298,7 @@ async def session_voice_tracker(client, member, before, after): pending.cancel() if after.channel: - blacklist = client.objects['blacklisted_users'] + blacklist = client.user_blacklist() guild_blacklist = client.objects['ignored_members'][guild.id] untracked = untracked_channels.get(guild.id).data start_session = ( diff --git a/bot/modules/study/tracking/time_tracker.py b/bot/modules/study/tracking/time_tracker.py index 1cb35fa0..46f88ec7 100644 --- a/bot/modules/study/tracking/time_tracker.py +++ b/bot/modules/study/tracking/time_tracker.py @@ -47,7 +47,7 @@ def _scan(guild): members = itertools.chain(*channel_members) # TODO filter out blacklisted users - blacklist = client.objects['blacklisted_users'] + blacklist = client.user_blacklist() guild_blacklist = client.objects['ignored_members'][guild.id] for member in members: diff --git a/bot/modules/sysadmin/blacklist.py b/bot/modules/sysadmin/blacklist.py index 12a2ed9b..90202407 100644 --- a/bot/modules/sysadmin/blacklist.py +++ b/bot/modules/sysadmin/blacklist.py @@ -7,6 +7,8 @@ import discord from cmdClient.checks import is_owner from cmdClient.lib import ResponseTimedOut +from meta.sharding import sharded + from .module import module @@ -26,14 +28,14 @@ async def cmd_guildblacklist(ctx, flags): Description: View, add, or remove guilds from the blacklist. """ - blacklist = ctx.client.objects['blacklisted_guilds'] + blacklist = ctx.client.guild_blacklist() if ctx.args: # guildid parsing items = [item.strip() for item in ctx.args.split(',')] if any(not item.isdigit() for item in items): return await ctx.error_reply( - "Please provide guilds as comma seprated guild ids." + "Please provide guilds as comma separated guild ids." ) guildids = set(int(item) for item in items) @@ -80,9 +82,18 @@ async def cmd_guildblacklist(ctx, flags): insert_keys=('guildid', 'ownerid', 'reason') ) - # Check if we are in any of these guilds - to_leave = (ctx.client.get_guild(guildid) for guildid in to_add) - to_leave = [guild for guild in to_leave if guild is not None] + # Leave freshly blacklisted guilds, accounting for shards + to_leave = [] + for guildid in to_add: + guild = ctx.client.get_guild(guildid) + if not guild and sharded: + try: + guild = await ctx.client.fetch_guild(guildid) + except discord.HTTPException: + pass + if guild: + to_leave.append(guild) + for guild in to_leave: await guild.leave() @@ -102,9 +113,8 @@ async def cmd_guildblacklist(ctx, flags): ) # Refresh the cached blacklist after modification - ctx.client.objects['blacklisted_guilds'] = set( - row['guildid'] for row in ctx.client.data.global_guild_blacklist.select_where() - ) + ctx.client.guild_blacklist.cache_clear() + ctx.client.guild_blacklist() else: # Display the current blacklist # First fetch the full blacklist data @@ -183,7 +193,7 @@ async def cmd_userblacklist(ctx, flags): Description: View, add, or remove users from the blacklist. """ - blacklist = ctx.client.objects['blacklisted_users'] + blacklist = ctx.client.user_blacklist() if ctx.args: # userid parsing @@ -245,9 +255,8 @@ async def cmd_userblacklist(ctx, flags): ) # Refresh the cached blacklist after modification - ctx.client.objects['blacklisted_users'] = set( - row['userid'] for row in ctx.client.data.global_user_blacklist.select_where() - ) + ctx.client.user_blacklist.cache_clear() + ctx.client.user_blacklist() else: # Display the current blacklist # First fetch the full blacklist data diff --git a/bot/modules/workout/tracker.py b/bot/modules/workout/tracker.py index 90eea397..79dc9378 100644 --- a/bot/modules/workout/tracker.py +++ b/bot/modules/workout/tracker.py @@ -170,7 +170,7 @@ async def workout_voice_tracker(client, member, before, after): if member.bot: return - if member.id in client.objects['blacklisted_users']: + if member.id in client.user_blacklist(): return if member.id in client.objects['ignored_members'][member.guild.id]: return From 25e22c07d0ad72590695df53b2a3b310592d7549 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 13:20:27 +0200 Subject: [PATCH 36/56] sharding (tickets): Filter expiring tickets. Only expire tickets which are on this shard. `THIS_SHARD` application is a no-op when unsharded. --- bot/data/conditions.py | 5 +++-- bot/modules/moderation/tickets/Ticket.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/data/conditions.py b/bot/data/conditions.py index 52999504..a314616e 100644 --- a/bot/data/conditions.py +++ b/bot/data/conditions.py @@ -80,8 +80,9 @@ class SHARDID(Condition): self.shard_count = shard_count def apply(self, key, values, conditions): - conditions.append("({} >> 22) %% {} = {}".format(key, self.shard_count, _replace_char)) - values.append(self.shardid) + if self.shard_count > 1: + conditions.append("({} >> 22) %% {} = {}".format(key, self.shard_count, _replace_char)) + values.append(self.shardid) THIS_SHARD = SHARDID(sharding.shard_number, sharding.shard_count) diff --git a/bot/modules/moderation/tickets/Ticket.py b/bot/modules/moderation/tickets/Ticket.py index 4d7ec5ec..afea1eef 100644 --- a/bot/modules/moderation/tickets/Ticket.py +++ b/bot/modules/moderation/tickets/Ticket.py @@ -6,6 +6,7 @@ import datetime import discord from meta import client +from data.conditions import THIS_SHARD from settings import GuildSettings from utils.lib import FieldEnum, strfdelta, utc_now @@ -283,7 +284,8 @@ class Ticket: # Get all expiring tickets expiring_rows = data.tickets.select_where( - ticket_state=TicketState.EXPIRING + ticket_state=TicketState.EXPIRING, + guildid=THIS_SHARD ) # Create new expiry tasks From 276886a3a70b2da61e34aa34cd444fefac73131b Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 17:26:49 +0200 Subject: [PATCH 37/56] sharding (general): Add launch data filters. Filter cached reaction role messages by shardid. Filter expiring rented room by shardid. Filter scanned study badges by shardid. Filter resumed study sessions by shardid. Filter resumed workouts by shardid. Fix a spacing issue in the log printer. --- bot/meta/logger.py | 2 +- bot/modules/guild_admin/reaction_roles/tracker.py | 3 ++- bot/modules/renting/rooms.py | 3 ++- bot/modules/study/badges/badge_tracker.py | 12 ++++++++---- bot/modules/study/tracking/session_tracker.py | 3 ++- bot/modules/workout/tracker.py | 4 +++- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/bot/meta/logger.py b/bot/meta/logger.py index 3e7bd026..a95500e4 100644 --- a/bot/meta/logger.py +++ b/bot/meta/logger.py @@ -17,7 +17,7 @@ logger = logging.getLogger() log_fmt = logging.Formatter( fmt=('[{asctime}][{levelname:^8}]' + '[SHARD {}]'.format(sharding.shard_number) + - '{message}'), + ' {message}'), datefmt='%d/%m | %H:%M:%S', style='{' ) diff --git a/bot/modules/guild_admin/reaction_roles/tracker.py b/bot/modules/guild_admin/reaction_roles/tracker.py index f18e3c34..17a64960 100644 --- a/bot/modules/guild_admin/reaction_roles/tracker.py +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -12,6 +12,7 @@ from discord import PartialEmoji from meta import client from core import Lion from data import Row +from data.conditions import THIS_SHARD from utils.lib import utc_now from settings import GuildSettings @@ -584,5 +585,5 @@ def load_reaction_roles(client): """ Load the ReactionRoleMessages. """ - rows = reaction_role_messages.fetch_rows_where() + rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD) ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows} diff --git a/bot/modules/renting/rooms.py b/bot/modules/renting/rooms.py index 3e1d19c4..a8c29876 100644 --- a/bot/modules/renting/rooms.py +++ b/bot/modules/renting/rooms.py @@ -5,6 +5,7 @@ import datetime from cmdClient.lib import SafeCancellation from meta import client +from data.conditions import THIS_SHARD from settings import GuildSettings from .data import rented, rented_members @@ -276,7 +277,7 @@ class Room: @module.launch_task async def load_rented_rooms(client): - rows = rented.fetch_rows_where() + rows = rented.fetch_rows_where(guildid=THIS_SHARD) for row in rows: Room(row.channelid).schedule() client.log( diff --git a/bot/modules/study/badges/badge_tracker.py b/bot/modules/study/badges/badge_tracker.py index 2c0d33fb..721f3962 100644 --- a/bot/modules/study/badges/badge_tracker.py +++ b/bot/modules/study/badges/badge_tracker.py @@ -6,8 +6,8 @@ import contextlib import discord -from meta import client -from data.conditions import GEQ +from meta import client, sharding +from data.conditions import GEQ, THIS_SHARD from core.data import lions from utils.lib import strfdur from settings import GuildSettings @@ -54,12 +54,16 @@ async def update_study_badges(full=False): # Retrieve member rows with out of date study badges if not full and client.appdata.last_study_badge_scan is not None: + # TODO: _extra here is a hack to cover for inflexible conditionals update_rows = new_study_badges.select_where( + guildid=THIS_SHARD, _timestamp=GEQ(client.appdata.last_study_badge_scan or 0), - _extra="OR session_start IS NOT NULL" + _extra="OR session_start IS NOT NULL AND (guildid >> 22) %% {} = {}".format( + sharding.shard_count, sharding.shard_number + ) ) else: - update_rows = new_study_badges.select_where() + update_rows = new_study_badges.select_where(guildid=THIS_SHARD) if not update_rows: client.appdata.last_study_badge_scan = datetime.datetime.utcnow() diff --git a/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 9cbe53be..8158f96a 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -7,6 +7,7 @@ from collections import defaultdict from utils.lib import utc_now from data import tables +from data.conditions import THIS_SHARD from core import Lion from meta import client @@ -398,7 +399,7 @@ async def _init_session_tracker(client): ended = 0 # Grab all ongoing sessions from data - rows = current_sessions.fetch_rows_where() + rows = current_sessions.fetch_rows_where(guildid=THIS_SHARD) # Iterate through, resume or end as needed for row in rows: diff --git a/bot/modules/workout/tracker.py b/bot/modules/workout/tracker.py index 79dc9378..be3438df 100644 --- a/bot/modules/workout/tracker.py +++ b/bot/modules/workout/tracker.py @@ -7,6 +7,7 @@ from core import Lion from settings import GuildSettings from meta import client from data import NULL, tables +from data.conditions import THIS_SHARD from .module import module from .data import workout_sessions @@ -226,7 +227,8 @@ async def load_workouts(client): client.objects['current_workouts'] = {} # (guildid, userid) -> Row # Process any incomplete workouts workouts = workout_sessions.fetch_rows_where( - duration=NULL + duration=NULL, + guildid=THIS_SHARD ) count = 0 for workout in workouts: From 68ff40cb0b1b9a5e8a66d32da5814fec1c0b8b0d Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 17:42:33 +0200 Subject: [PATCH 38/56] sharding (status): Use sessions for bot status. Uses the `current_sessions` table to generate the status summary. --- bot/modules/sysadmin/status.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bot/modules/sysadmin/status.py b/bot/modules/sysadmin/status.py index 83f02cc8..853f6410 100644 --- a/bot/modules/sysadmin/status.py +++ b/bot/modules/sysadmin/status.py @@ -13,19 +13,13 @@ async def update_status(): # TODO: Make globally configurable and saveable global _last_update - if time.time() - _last_update < 30: + if time.time() - _last_update < 60: return _last_update = time.time() - student_count = sum( - len(ch.members) - for guild in client.guilds - for ch in guild.voice_channels - ) - room_count = sum( - len([vc for vc in guild.voice_channels if vc.members]) - for guild in client.guilds + student_count, room_count = client.data.current_sessions.select_one_where( + select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(channelid)) AS channel_count"), ) status = "{} students in {} study rooms!".format(student_count, room_count) From 0dd5213f13a8ca5793ae9ff5bbbf498e5e94bee4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 19:07:28 +0200 Subject: [PATCH 39/56] sharding (accountability): Adapt for sharding. Filter initially loaded accountability guilds. Filter timeslots loaded in `open_next`. Reload members and overwrites on slot start. --- bot/modules/accountability/TimeSlot.py | 29 +++++++++++++++++++++++- bot/modules/accountability/tracker.py | 31 ++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index 9464e4e5..81cbe38d 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -90,7 +90,6 @@ class TimeSlot: @property def open_embed(self): - # TODO Consider adding hint to footer timestamp = int(self.start_time.timestamp()) embed = discord.Embed( @@ -247,6 +246,34 @@ class TimeSlot: return self + async def _reload_members(self, memberids=None): + """ + Reload the timeslot members from the provided list, or data. + Also updates the channel overwrites if required. + To be used before the session has started. + """ + if self.data: + if memberids is None: + member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid) + memberids = [row.userid for row in member_rows] + + self.members = members = { + memberid: SlotMember(self.data.slotid, memberid, self.guild) + for memberid in memberids + } + + if self.channel: + # Check and potentially update overwrites + current_overwrites = self.channel.overwrites + overwrites = { + mem.member: self._member_overwrite + for mem in members.values() + if mem.member + } + overwrites[self.guild.default_role] = self._everyone_overwrite + if current_overwrites != overwrites: + await self.channel.edit(overwrites=overwrites) + def _refresh(self): """ Refresh the stored data row and reload. diff --git a/bot/modules/accountability/tracker.py b/bot/modules/accountability/tracker.py index 24e1dc94..faa82867 100644 --- a/bot/modules/accountability/tracker.py +++ b/bot/modules/accountability/tracker.py @@ -10,7 +10,7 @@ from discord.utils import sleep_until from meta import client from utils.interactive import discord_shield from data import NULL, NOTNULL, tables -from data.conditions import LEQ +from data.conditions import LEQ, THIS_SHARD from settings import GuildSettings from .TimeSlot import TimeSlot @@ -67,7 +67,8 @@ async def open_next(start_time): """ # Pre-fetch the new slot data, also populating the table caches room_data = accountability_rooms.fetch_rows_where( - start_at=start_time + start_at=start_time, + guildid=THIS_SHARD ) guild_rows = {row.guildid: row for row in room_data} member_data = accountability_members.fetch_rows_where( @@ -193,11 +194,30 @@ async def turnover(): # TODO: (FUTURE) with high volume, we might want to start the sessions before moving the members. # We could break up the session starting? - # Move members of the next session over to the session channel + # ---------- Start next session ---------- current_slots = [ aguild.current_slot for aguild in AccountabilityGuild.cache.values() if aguild.current_slot is not None ] + slotmap = {slot.data.slotid: slot for slot in current_slots if slot.data} + + # Reload the slot members in case they cancelled from another shard + member_data = accountability_members.fetch_rows_where( + slotid=list(slotmap.keys()) + ) if slotmap else [] + slot_memberids = {slotid: [] for slotid in slotmap} + for row in member_data: + slot_memberids[row.slotid].append(row.userid) + reload_tasks = ( + slot._reload_members(memberids=slot_memberids[slotid]) + for slotid, slot in slotmap.items() + ) + await asyncio.gather( + *reload_tasks, + return_exceptions=True + ) + + # Move members of the next session over to the session channel movement_tasks = ( mem.member.edit( voice_channel=slot.channel, @@ -335,6 +355,7 @@ async def _accountability_system_resume(): open_room_data = accountability_rooms.fetch_rows_where( closed_at=NULL, start_at=LEQ(now), + guildid=THIS_SHARD, _extra="ORDER BY start_at ASC" ) @@ -450,8 +471,10 @@ async def launch_accountability_system(client): """ # Load the AccountabilityGuild cache guilds = tables.guild_config.fetch_rows_where( - accountability_category=NOTNULL + accountability_category=NOTNULL, + guildid=THIS_SHARD ) + # Further filter out any guilds that we aren't in [AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)] await _accountability_system_resume() asyncio.create_task(_accountability_loop()) From e979e5cf45937cd4b7dedf183319e73bc60a6310 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 22 Dec 2021 20:24:24 +0200 Subject: [PATCH 40/56] sharding (reminders): Adapt for sharding. Restrict reminder execution to shard `0`. Add a poll on shard `0` to pick up new reminders. Check whether the reminder still exists on execution. --- bot/modules/reminders/commands.py | 8 ++-- bot/modules/reminders/reminder.py | 79 ++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/bot/modules/reminders/commands.py b/bot/modules/reminders/commands.py index 0bb98d60..c8637a04 100644 --- a/bot/modules/reminders/commands.py +++ b/bot/modules/reminders/commands.py @@ -3,6 +3,7 @@ import asyncio import datetime import discord +from meta import sharding from utils.lib import parse_dur, parse_ranges, multiselect_regex from .module import module @@ -55,7 +56,7 @@ async def cmd_remindme(ctx, flags): if not rows: return await ctx.reply("You have no reminders to remove!") - live = Reminder.fetch(*(row.reminderid for row in rows)) + live = [Reminder(row.reminderid) for row in rows] if not ctx.args: lines = [] @@ -209,7 +210,8 @@ async def cmd_remindme(ctx, flags): ) # Schedule reminder - reminder.schedule() + if sharding.shard_number == 0: + reminder.schedule() # Ack embed = discord.Embed( @@ -231,7 +233,7 @@ async def cmd_remindme(ctx, flags): if not rows: return await ctx.reply("You have no reminders!") - live = Reminder.fetch(*(row.reminderid for row in rows)) + live = [Reminder(row.reminderid) for row in rows] lines = [] num_field = len(str(len(live) - 1)) diff --git a/bot/modules/reminders/reminder.py b/bot/modules/reminders/reminder.py index 73870341..67956a1d 100644 --- a/bot/modules/reminders/reminder.py +++ b/bot/modules/reminders/reminder.py @@ -1,8 +1,9 @@ import asyncio import datetime +import logging import discord -from meta import client +from meta import client, sharding from utils.lib import strfdur from .data import reminders @@ -46,7 +47,10 @@ class Reminder: cls._live_reminders[reminderid].cancel() # Remove from data - reminders.delete_where(reminderid=reminderids) + if reminderids: + return reminders.delete_where(reminderid=reminderids) + else: + return [] @property def data(self): @@ -134,10 +138,16 @@ class Reminder: """ Execute the reminder. """ + if not self.data: + # Reminder deleted elsewhere + return + if self.data.userid in client.user_blacklist(): self.delete(self.reminderid) return + userid = self.data.userid + # Build the message embed embed = discord.Embed( title="You asked me to remind you!", @@ -155,8 +165,26 @@ class Reminder: ) ) + # Update the reminder data, and reschedule if required + if self.data.interval: + next_time = self.data.remind_at + datetime.timedelta(seconds=self.data.interval) + rows = reminders.update_where( + {'remind_at': next_time}, + reminderid=self.reminderid + ) + self.schedule() + else: + rows = self.delete(self.reminderid) + if not rows: + # Reminder deleted elsewhere + return + # Send the message, if possible - user = self.user + if not (user := client.get_user(userid)): + try: + user = await client.fetch_user(userid) + except discord.HTTPException: + pass if user: try: await user.send(embed=embed) @@ -164,21 +192,38 @@ class Reminder: # Nothing we can really do here. Maybe tell the user about their reminder next time? pass - # Update the reminder data, and reschedule if required - if self.data.interval: - next_time = self.data.remind_at + datetime.timedelta(seconds=self.data.interval) - reminders.update_where({'remind_at': next_time}, reminderid=self.reminderid) - self.schedule() - else: - self.delete(self.reminderid) + +async def reminder_poll(client): + """ + One client/shard must continually poll for new or deleted reminders. + """ + # TODO: Clean this up with database signals or IPC + while True: + await asyncio.sleep(60) + + client.log( + "Running new reminder poll.", + context="REMINDERS", + level=logging.DEBUG + ) + + rids = {row.reminderid for row in reminders.fetch_rows_where()} + + to_delete = (rid for rid in Reminder._live_reminders if rid not in rids) + Reminder.delete(*to_delete) + + [Reminder(rid).schedule() for rid in rids if rid not in Reminder._live_reminders] @module.launch_task async def schedule_reminders(client): - rows = reminders.fetch_rows_where() - for row in rows: - Reminder(row.reminderid).schedule() - client.log( - "Scheduled {} reminders.".format(len(rows)), - context="LAUNCH_REMINDERS" - ) + if sharding.shard_number == 0: + rows = reminders.fetch_rows_where() + for row in rows: + Reminder(row.reminderid).schedule() + client.log( + "Scheduled {} reminders.".format(len(rows)), + context="LAUNCH_REMINDERS" + ) + if sharding.sharded: + asyncio.create_task(reminder_poll(client)) From 46fd2b65e45d4a57d10f967f054440d48d0541d1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 28 Dec 2021 00:27:59 +0200 Subject: [PATCH 41/56] fix (rooms): Ignore vanishing lobby channel. --- bot/modules/accountability/TimeSlot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index 81cbe38d..03ade394 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -231,7 +231,7 @@ class TimeSlot: self.channel = self.guild.get_channel(self.data.channelid) # Load message - if self.data.messageid: + if self.data.messageid and self.lobby: self.message = discord.PartialMessage( channel=self.lobby, id=self.data.messageid From 36f92add4ebcaba6bc4fb5602e7795f85a379d01 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 28 Dec 2021 15:15:47 +0200 Subject: [PATCH 42/56] (todo): Small refactor. Refactor `Tasklist` to allow for alternative (plugin) implementation. --- bot/modules/todo/Tasklist.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index e16ff866..dd6b6880 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -135,7 +135,7 @@ class Tasklist: ) self._refreshed_at = datetime.datetime.utcnow() - def _format_tasklist(self): + async def _format_tasklist(self): """ Generates a sequence of pages from the tasklist """ @@ -176,7 +176,7 @@ class Tasklist: hint = "Type `add ` to start adding tasks! E.g. `add Revise Maths Paper 1`." task_blocks = [""] # Empty page so we can post - # Create formtted page embeds, adding help if required + # Create formatted page embeds, adding help if required pages = [] for i, block in enumerate(task_blocks): embed = discord.Embed( @@ -233,6 +233,12 @@ class Tasklist: self.message = message self.messages[message.id] = self + async def _update(self): + """ + Update the current message with the current page. + """ + await self.message.edit(embed=self.pages[self.current_page]) + async def update(self, repost=None): """ Update the displayed tasklist. @@ -243,7 +249,7 @@ class Tasklist: # Update data and make page list self._refresh() - self._format_tasklist() + await self._format_tasklist() self._adjust_current_page() if self.message and not repost: @@ -266,7 +272,8 @@ class Tasklist: if not repost: try: - await self.message.edit(embed=self.pages[self.current_page]) + # TODO: Refactor into update method + await self._update() # Add or remove paging reactions as required should_have_paging = len(self.pages) > 1 @@ -572,21 +579,21 @@ class Tasklist: self.current_page %= len(self.pages) if self.show_help: self.show_help = False - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.prev_emoji and user.id == self.member.id: self.current_page -= 1 self.current_page %= len(self.pages) if self.show_help: self.show_help = False - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.cancel_emoji and user.id == self.member.id: await self.deactivate(delete=True) elif str_emoji == self.question_emoji and user.id == self.member.id: self.show_help = not self.show_help - self._format_tasklist() - await self.message.edit(embed=self.pages[self.current_page]) + await self._format_tasklist() + await self._update() elif str_emoji == self.refresh_emoji and user.id == self.member.id: await self.update() From e2c096f350e4f75470a836fa31e53e55ea1e47b8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 08:27:53 +0200 Subject: [PATCH 43/56] (todo): Add extra metrics for stats. v6 -> v7 data migration. Use soft deletion for tasks. Remove task expiry. Migrate `complete` field to `completed_at`. --- bot/modules/todo/Tasklist.py | 61 +++++++++++++++--------------- bot/modules/todo/data.py | 14 +------ data/migration/v6-v7/migration.sql | 18 +++++++++ data/schema.sql | 7 ++-- 4 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 data/migration/v6-v7/migration.sql diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index dd6b6880..f1b2ebac 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -6,8 +6,9 @@ import asyncio from cmdClient.lib import SafeCancellation from meta import client from core import Lion +from data import NULL, NOTNULL from settings import GuildSettings -from utils.lib import parse_ranges +from utils.lib import parse_ranges, utc_now from . import data # from .module import module @@ -130,8 +131,8 @@ class Tasklist: """ self.tasklist = data.tasklist.fetch_rows_where( userid=self.member.id, - _extra=("AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h' " - "ORDER BY created_at ASC, taskid ASC") + deleted_at=NULL, + _extra="ORDER BY created_at ASC, taskid ASC" ) self._refreshed_at = datetime.datetime.utcnow() @@ -144,7 +145,7 @@ class Tasklist: "{num:>{numlen}}. [{mark}] {content}".format( num=i, numlen=((self.block_size * (i // self.block_size + 1) - 1) // 10) + 1, - mark=self.checkmark if task.complete else ' ', + mark=self.checkmark if task.completed_at else ' ', content=task.content ) for i, task in enumerate(self.tasklist) @@ -159,7 +160,7 @@ class Tasklist: # Formatting strings and data page_count = len(task_blocks) or 1 task_count = len(task_strings) - complete_count = len([task for task in self.tasklist if task.complete]) + complete_count = len([task for task in self.tasklist if task.completed_at]) if task_count > 0: title = "TODO list ({}/{} complete)".format( @@ -205,7 +206,7 @@ class Tasklist: # Calculate or adjust the current page number if self.current_page is None: # First page with incomplete task, or the first page - first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.complete), 0) + first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.completed_at), 0) self.current_page = first_incomplete // self.block_size elif self.current_page >= len(self.pages): self.current_page = len(self.pages) - 1 @@ -394,8 +395,14 @@ class Tasklist: Delete tasks from the task list """ taskids = [self.tasklist[i].taskid for i in indexes] - return data.tasklist.delete_where( - taskid=taskids + + now = utc_now() + return data.tasklist.update_where( + { + 'deleted_at': now, + 'last_updated_at': now + }, + taskid=taskids, ) def _edit_task(self, index, new_content): @@ -403,10 +410,12 @@ class Tasklist: Update the provided task with the new content """ taskid = self.tasklist[index].taskid + + now = utc_now() return data.tasklist.update_where( { 'content': new_content, - 'last_updated_at': datetime.datetime.utcnow() + 'last_updated_at': now }, taskid=taskid, ) @@ -416,13 +425,15 @@ class Tasklist: Mark provided tasks as complete """ taskids = [self.tasklist[i].taskid for i in indexes] + + now = utc_now() return data.tasklist.update_where( { - 'complete': True, - 'last_updated_at': datetime.datetime.utcnow() + 'completed_at': now, + 'last_updated_at': now }, taskid=taskids, - complete=False, + completed_at=NULL, ) def _uncheck_tasks(self, *indexes): @@ -430,13 +441,15 @@ class Tasklist: Mark provided tasks as incomplete """ taskids = [self.tasklist[i].taskid for i in indexes] + + now = utc_now() return data.tasklist.update_where( { - 'complete': False, - 'last_updated_at': datetime.datetime.utcnow() + 'completed_at': None, + 'last_updated_at': now }, taskid=taskids, - complete=True, + completed_at=NOTNULL, ) def _index_range_parser(self, userstr): @@ -466,7 +479,7 @@ class Tasklist: count = data.tasklist.select_one_where( select_columns=("COUNT(*)",), userid=self.member.id, - _extra="AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h'" + deleted_at=NOTNULL )[0] # Fetch maximum allowed count @@ -503,8 +516,8 @@ class Tasklist: # Parse provided ranges indexes = self._index_range_parser(userstr) - to_check = [index for index in indexes if not self.tasklist[index].complete] - to_uncheck = [index for index in indexes if self.tasklist[index].complete] + to_check = [index for index in indexes if not self.tasklist[index].completed_at] + to_uncheck = [index for index in indexes if self.tasklist[index].completed_at] if to_uncheck: self._uncheck_tasks(*to_uncheck) @@ -694,15 +707,3 @@ async def tasklist_message_handler(client, message): async def tasklist_reaction_add_handler(client, reaction, user): if user != client.user and reaction.message.id in Tasklist.messages: await Tasklist.messages[reaction.message.id].handle_reaction(reaction, user, True) - - -# @module.launch_task -# Commented because we don't actually need to expire these -async def tasklist_expiry_watchdog(client): - removed = data.tasklist.queries.expire_old_tasks() - if removed: - client.log( - "Remove {} stale todo tasks.".format(len(removed)), - context="TASKLIST_EXPIRY", - post=True - ) diff --git a/bot/modules/todo/data.py b/bot/modules/todo/data.py index f340197a..be2d8c65 100644 --- a/bot/modules/todo/data.py +++ b/bot/modules/todo/data.py @@ -2,23 +2,11 @@ from data import RowTable, Table tasklist = RowTable( 'tasklist', - ('taskid', 'userid', 'content', 'complete', 'rewarded', 'created_at', 'last_updated_at'), + ('taskid', 'userid', 'content', 'rewarded', 'created_at', 'completed_at', 'deleted_at', 'last_updated_at'), 'taskid' ) -@tasklist.save_query -def expire_old_tasks(): - with tasklist.conn: - with tasklist.conn.cursor() as curs: - curs.execute( - "DELETE FROM tasklist WHERE " - "last_updated_at < timezone('utc', NOW()) - INTERVAL '7d' " - "RETURNING *" - ) - return curs.fetchall() - - tasklist_channels = Table('tasklist_channels') tasklist_rewards = Table('tasklist_reward_history') diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql new file mode 100644 index 00000000..04cc39da --- /dev/null +++ b/data/migration/v6-v7/migration.sql @@ -0,0 +1,18 @@ +-- Add deletion column to tasklist entries +-- Add completed_at column to the tasklist entries, replacing complete + + +ALTER TABLE tasklist + ADD COLUMN completed_at TIMESTAMPTZ, + ADD COLUMN deleted_at TIMESTAMPTZ, + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN last_updated_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +UPDATE tasklist SET deleted_at = NOW() WHERE last_updated_at < NOW() - INTERVAL '24h'; +UPDATE tasklist SET completed_at = last_updated_at WHERE complete; + +ALTER TABLE tasklist + DROP COLUMN complete; + + +-- Mark all tasklist entries older than a day as deleted diff --git a/data/schema.sql b/data/schema.sql index a7a4af31..cf960112 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -135,10 +135,11 @@ CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, userid BIGINT NOT NULL, content TEXT NOT NULL, - complete BOOL DEFAULT FALSE, rewarded BOOL DEFAULT FALSE, - created_at TIMESTAMP DEFAULT (now() at time zone 'utc'), - last_updated_at TIMESTAMP DEFAULT (now() at time zone 'utc') + deleted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ, + last_updated_at TIMESTAMPTZ ); CREATE INDEX tasklist_users ON tasklist (userid); From 6aaa2377a4e2e2ff8642253e80a149ea63d90bd4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:13:24 +0200 Subject: [PATCH 44/56] refactor: Split `stats` module from `study`. --- bot/modules/__init__.py | 1 + bot/modules/stats/__init__.py | 6 ++++++ bot/modules/stats/data.py | 13 +++++++++++++ bot/modules/stats/module.py | 4 ++++ .../{study/stats_cmd.py => stats/profile.py} | 2 +- bot/modules/{study => stats}/top_cmd.py | 0 bot/modules/study/__init__.py | 3 --- bot/modules/study/module.py | 2 +- 8 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 bot/modules/stats/__init__.py create mode 100644 bot/modules/stats/data.py create mode 100644 bot/modules/stats/module.py rename bot/modules/{study/stats_cmd.py => stats/profile.py} (99%) rename bot/modules/{study => stats}/top_cmd.py (100%) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py index 9a6a51ae..b1763cae 100644 --- a/bot/modules/__init__.py +++ b/bot/modules/__init__.py @@ -3,6 +3,7 @@ from .guild_admin import * from .meta import * from .economy import * from .study import * +from .stats import * from .user_config import * from .workout import * from .todo import * diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py new file mode 100644 index 00000000..4478e63d --- /dev/null +++ b/bot/modules/stats/__init__.py @@ -0,0 +1,6 @@ +from .module import module + +from . import data +from . import profile +from . import setprofile +from . import top_cmd diff --git a/bot/modules/stats/data.py b/bot/modules/stats/data.py new file mode 100644 index 00000000..79bcb7a9 --- /dev/null +++ b/bot/modules/stats/data.py @@ -0,0 +1,13 @@ +from data import Table + + +profile_tags = Table('member_profile_tags', attach_as='profile_tags') + + +@profile_tags.save_query +def get_tags_for(guildid, userid): + rows = profile_tags.select_where( + guildid=guildid, userid=userid, + _extra="ORDER BY tagid ASC" + ) + return [row['tag'] for row in rows] diff --git a/bot/modules/stats/module.py b/bot/modules/stats/module.py new file mode 100644 index 00000000..d820c4de --- /dev/null +++ b/bot/modules/stats/module.py @@ -0,0 +1,4 @@ +from LionModule import LionModule + + +module = LionModule("Statistics") diff --git a/bot/modules/study/stats_cmd.py b/bot/modules/stats/profile.py similarity index 99% rename from bot/modules/study/stats_cmd.py rename to bot/modules/stats/profile.py index 88bc8be5..09239ad8 100644 --- a/bot/modules/study/stats_cmd.py +++ b/bot/modules/stats/profile.py @@ -7,7 +7,7 @@ from data import tables from data.conditions import LEQ from core import Lion -from .tracking.data import session_history +from modules.study.tracking.data import session_history from .module import module diff --git a/bot/modules/study/top_cmd.py b/bot/modules/stats/top_cmd.py similarity index 100% rename from bot/modules/study/top_cmd.py rename to bot/modules/stats/top_cmd.py diff --git a/bot/modules/study/__init__.py b/bot/modules/study/__init__.py index 30f59149..8e7830b0 100644 --- a/bot/modules/study/__init__.py +++ b/bot/modules/study/__init__.py @@ -3,6 +3,3 @@ from .module import module from . import badges from . import timers from . import tracking - -from . import top_cmd -from . import stats_cmd diff --git a/bot/modules/study/module.py b/bot/modules/study/module.py index ae88f7dd..38f5340a 100644 --- a/bot/modules/study/module.py +++ b/bot/modules/study/module.py @@ -1,4 +1,4 @@ from LionModule import LionModule -module = LionModule("Study_Stats") +module = LionModule("Study_Tracking") From 46bf03ae25b54f9acea10dbb2892f2864dd39154 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:20:17 +0200 Subject: [PATCH 45/56] feature (setprofile): Profile tag editor. --- bot/modules/stats/setprofile.py | 216 +++++++++++++++++++++++++++++ data/migration/v6-v7/migration.sql | 18 ++- data/schema.sql | 17 ++- 3 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 bot/modules/stats/setprofile.py diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py new file mode 100644 index 00000000..766eb5ea --- /dev/null +++ b/bot/modules/stats/setprofile.py @@ -0,0 +1,216 @@ +""" +Provides a command to update a member's profile badges. +""" +import string +import discord + +from cmdClient.lib import SafeCancellation +from cmdClient.checks import in_guild +from wards import guild_moderator + +from .data import profile_tags +from .module import module + + +MAX_TAGS = 5 +MAX_LENGTH = 30 + + +@module.cmd( + "setprofile", + group="Personal Settings", + desc="Set or update your study profile tags.", + aliases=('editprofile', 'mytags'), + flags=('clear', 'for') +) +@in_guild() +async def cmd_setprofile(ctx, flags): + """ + Usage``: + {prefix}setprofile , , , ... + {prefix}setprofile + {prefix}setprofile --clear [--for @user] + Description: + Set or update the tags appearing in your study server profile. + + You can have at most `5` tags at once. + + Moderators can clear a user's tags with `--clear --for @user`. + Examples``: + {prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe + {prefix}setprofile 2 Biology + {prefix}setprofile --clear + """ + if flags['clear']: + if flags['for']: + # Moderator-clearing a user's tags + # First check moderator permissions + if not await guild_moderator.run(ctx): + return await ctx.error_reply( + "You need to be a server moderator to use this!" + ) + + # Check input and extract users to clear for + if not (users := ctx.msg.mentions): + # Show moderator usage + return await ctx.error_reply( + f"**Usage:** `{ctx.best_prefix}setprofile --clear --for @user`\n" + f"**Example:** {ctx.best_prefix}setprofile --clear --for {ctx.author.mention}" + ) + + # Clear the tags + profile_tags.delete_where( + guildid=ctx.guild.id, + userid=[user.id for user in users] + ) + + # Ack the moderator + await ctx.embed_reply( + "Profile tags cleared!" + ) + else: + # The author wants to clear their own tags + + # First delete the tags, save the rows for reporting + rows = profile_tags.delete_where( + guildid=ctx.guild.id, + userid=ctx.author.id + ) + + # Ack the user + if not rows: + await ctx.embed_reply( + "You don't have any profile tags to clear!" + ) + else: + embed = discord.Embed( + colour=discord.Colour.green(), + description="Successfully cleared your profile!" + ) + embed.add_field( + name="Removed tags", + value='\n'.join(row['tag'].upper() for row in rows) + ) + await ctx.reply(embed=embed) + elif ctx.args: + if len(splits := ctx.args.split(maxsplit=1)) > 1 and splits[0].isdigit(): + # Assume we are editing the provided id + tagid = int(splits[0]) + if tagid > MAX_TAGS: + return await ctx.error_reply( + f"Sorry, you can have a maximum of `{MAX_TAGS}` tags!" + ) + + # Retrieve the user's current taglist + rows = profile_tags.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + _extra="ORDER BY tagid ASC" + ) + + # Parse and validate provided new content + content = splits[1].strip().upper() + validate_tag(content) + + if tagid > len(rows): + # Trying to edit a tag that doesn't exist yet + # Just create it instead + profile_tags.insert( + guildid=ctx.guild.id, + userid=ctx.author.id, + tag=content + ) + + # Ack user + await ctx.reply( + embed=discord.Embed(title="Tag created!", colour=discord.Colour.green()) + ) + else: + # Get the row id to update + to_edit = rows[tagid - 1]['tagid'] + + # Update the tag + profile_tags.update_where( + {'tag': content}, + tagid=to_edit + ) + + # Ack user + embed = discord.Embed( + colour=discord.Colour.green(), + title="Tag updated!" + ) + await ctx.reply(embed=embed) + else: + # Assume the arguments are a comma separated list of badges + # Parse and validate + to_add = [split.strip().upper() for split in ctx.args.split(',')] + validate_tag(*to_add) + + # Remove the existing badges + deleted_rows = profile_tags.delete_where( + guildid=ctx.guild.id, + userid=ctx.author.id + ) + + # Insert the new tags + profile_tags.insert_many( + *((ctx.guild.id, ctx.author.id, tag) for tag in to_add), + insert_keys=('guildid', 'userid', 'tag') + ) + + # Ack with user + embed = discord.Embed( + colour=discord.Colour.green(), + description="Profile tags updated!" + ) + embed.add_field( + name="New tags", + value='\n'.join(to_add) + ) + if deleted_rows: + embed.add_field( + name="Previous tags", + value='\n'.join(row['tag'].upper() for row in deleted_rows), + inline=False + ) + await ctx.reply(embed=embed) + else: + # No input was provided + # Show usage and exit + embed = discord.Embed( + colour=discord.Colour.red(), + description=( + "Use this command to edit your study profile " + "tags so other people can see what you do!" + ) + ) + embed.add_field( + name="Usage", + value=( + f"`{ctx.best_prefix}setprofile , , , ...`\n" + f"`{ctx.best_prefix}setprofile `" + ) + ) + embed.add_field( + name="Examples", + value=( + f"`{ctx.best_prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe`\n" + f"`{ctx.best_prefix}setprofile 2 Biology`" + ), + inline=False + ) + await ctx.reply(embed=embed) + + +def validate_tag(*content): + for content in content: + if not set(content).issubset(string.printable): + raise SafeCancellation( + f"Invalid tag `{content}`!\n" + "Tags may only contain alphanumeric and punctuation characters." + ) + if len(content) > MAX_LENGTH: + raise SafeCancellation( + f"Provided tag is too long! Please keep your tags shorter than {MAX_LENGTH} characters." + ) diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql index 04cc39da..3d4db031 100644 --- a/data/migration/v6-v7/migration.sql +++ b/data/migration/v6-v7/migration.sql @@ -1,7 +1,4 @@ --- Add deletion column to tasklist entries --- Add completed_at column to the tasklist entries, replacing complete - - +-- Improved tasklist statistics ALTER TABLE tasklist ADD COLUMN completed_at TIMESTAMPTZ, ADD COLUMN deleted_at TIMESTAMPTZ, @@ -15,4 +12,15 @@ ALTER TABLE tasklist DROP COLUMN complete; --- Mark all tasklist entries older than a day as deleted +-- New member profile tags +CREATE TABLE member_profile_tags( + tagid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + tag TEXT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); + + +INSERT INTO VersionHistory (version, author) VALUES (7, 'v6-v7 migration'); diff --git a/data/schema.sql b/data/schema.sql index cf960112..5edb3add 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (6, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (7, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -683,4 +683,19 @@ CREATE TABLE past_member_roles( CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); -- }}} +-- Member profile tags {{{ +CREATE TABLE member_profile_tags( + tagid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + tag TEXT NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) +); +-- }}} + +-- Member goals {{{ + +-- }}} + -- vim: set fdm=marker: From c5197257556875a8c42a201806d34875c0ce213d Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 20:49:49 +0200 Subject: [PATCH 46/56] fix (setprofile): Add check for max tasks. --- bot/modules/stats/setprofile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 766eb5ea..a3d5c4bb 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -12,7 +12,7 @@ from .data import profile_tags from .module import module -MAX_TAGS = 5 +MAX_TAGS = 10 MAX_LENGTH = 30 @@ -33,8 +33,6 @@ async def cmd_setprofile(ctx, flags): Description: Set or update the tags appearing in your study server profile. - You can have at most `5` tags at once. - Moderators can clear a user's tags with `--clear --for @user`. Examples``: {prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe @@ -147,6 +145,9 @@ async def cmd_setprofile(ctx, flags): to_add = [split.strip().upper() for split in ctx.args.split(',')] validate_tag(*to_add) + if len(to_add) > MAX_TAGS: + return await ctx.error_reply(f"You can have a maximum of {MAX_TAGS} tags!") + # Remove the existing badges deleted_rows = profile_tags.delete_where( guildid=ctx.guild.id, @@ -181,7 +182,7 @@ async def cmd_setprofile(ctx, flags): embed = discord.Embed( colour=discord.Colour.red(), description=( - "Use this command to edit your study profile " + "Edit your study profile " "tags so other people can see what you do!" ) ) From 113ff0379aae9edc6bf8e898846844cbf3027ab1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 21:55:25 +0200 Subject: [PATCH 47/56] fix (setprofile): Update tag parsing. --- bot/modules/stats/setprofile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index a3d5c4bb..8cc147bb 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -142,7 +142,8 @@ async def cmd_setprofile(ctx, flags): else: # Assume the arguments are a comma separated list of badges # Parse and validate - to_add = [split.strip().upper() for split in ctx.args.split(',')] + to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')] + to_add = [split.replace('<3', '❤️') for split in to_add if split] validate_tag(*to_add) if len(to_add) > MAX_TAGS: @@ -206,7 +207,7 @@ async def cmd_setprofile(ctx, flags): def validate_tag(*content): for content in content: - if not set(content).issubset(string.printable): + if not set(content.replace('❤️', '')).issubset(string.printable): raise SafeCancellation( f"Invalid tag `{content}`!\n" "Tags may only contain alphanumeric and punctuation characters." From b1bcee8cc6e7ad099c9da3a6879d1a3d78f4d689 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 29 Dec 2021 21:57:32 +0200 Subject: [PATCH 48/56] fix (setprofile): Guard against empty input. --- bot/modules/stats/setprofile.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 8cc147bb..8eeafffd 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -144,6 +144,9 @@ async def cmd_setprofile(ctx, flags): # Parse and validate to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')] to_add = [split.replace('<3', '❤️') for split in to_add if split] + if not to_add: + return await ctx.error_reply("No valid tags given, nothing to do!") + validate_tag(*to_add) if len(to_add) > MAX_TAGS: From fa430b840340eb22f5b359327576d113fd1a1cfa Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 10:57:30 +0200 Subject: [PATCH 49/56] fix (todo): Correctly filter deleted tasks. --- bot/modules/todo/Tasklist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index f1b2ebac..3618b734 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -479,7 +479,7 @@ class Tasklist: count = data.tasklist.select_one_where( select_columns=("COUNT(*)",), userid=self.member.id, - deleted_at=NOTNULL + deleted_at=NULL )[0] # Fetch maximum allowed count From d60a8772a3ff79a137ba939df59345f8aa68c173 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 11:26:58 +0200 Subject: [PATCH 50/56] feature (stats): Weekly and monthly goals. Add a new editing interface for weekly and monthly goals. Textual viewing interface is currently a stub. Add `month_timestamp` and `week_timestamp` lion properties. --- bot/core/lion.py | 31 ++++++++++++++++++ bot/modules/stats/__init__.py | 1 + bot/modules/stats/data.py | 28 ++++++++++++++++- bot/modules/stats/setprofile.py | 8 +++-- data/migration/v6-v7/migration.sql | 50 ++++++++++++++++++++++++++++++ data/schema.sql | 48 ++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 3 deletions(-) diff --git a/bot/core/lion.py b/bot/core/lion.py index c803cff8..4b3baea9 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -146,6 +146,37 @@ class Lion: now = datetime.now(tz=self.timezone) return now.replace(hour=0, minute=0, second=0, microsecond=0) + @property + def day_timestamp(self): + """ + EPOCH timestamp representing the current day for the user. + NOTE: This is the timestamp of the start of the current UTC day with the same date as the user's day. + This is *not* the start of the current user's day, either in UTC or their own timezone. + This may also not be the start of the current day in UTC (consider 23:00 for a user in UTC-2). + """ + now = datetime.now(tz=self.timezone) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return int(day_start.replace(tzinfo=pytz.utc).timestamp()) + + @property + def week_timestamp(self): + """ + EPOCH timestamp representing the current week for the user. + """ + now = datetime.now(tz=self.timezone) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = day_start - timedelta(days=day_start.weekday()) + return int(week_start.replace(tzinfo=pytz.utc).timestamp()) + + @property + def month_timestamp(self): + """ + EPOCH timestamp representing the current month for the user. + """ + now = datetime.now(tz=self.timezone) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return int(month_start.replace(tzinfo=pytz.utc).timestamp()) + @property def remaining_in_day(self): return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds() diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py index 4478e63d..cf342274 100644 --- a/bot/modules/stats/__init__.py +++ b/bot/modules/stats/__init__.py @@ -4,3 +4,4 @@ from . import data from . import profile from . import setprofile from . import top_cmd +from . import goals diff --git a/bot/modules/stats/data.py b/bot/modules/stats/data.py index 79bcb7a9..234b226c 100644 --- a/bot/modules/stats/data.py +++ b/bot/modules/stats/data.py @@ -1,4 +1,6 @@ -from data import Table +from cachetools import TTLCache + +from data import Table, RowTable profile_tags = Table('member_profile_tags', attach_as='profile_tags') @@ -11,3 +13,27 @@ def get_tags_for(guildid, userid): _extra="ORDER BY tagid ASC" ) return [row['tag'] for row in rows] + + +weekly_goals = RowTable( + 'member_weekly_goals', + ('guildid', 'userid', 'weekid', 'study_goal', 'task_goal'), + ('guildid', 'userid', 'weekid'), + cache=TTLCache(5000, 60 * 60 * 24), + attach_as='weekly_goals' +) + + +# NOTE: Not using a RowTable here since these will almost always be mass-selected +weekly_tasks = Table('member_weekly_goal_tasks') + + +monthly_goals = RowTable( + 'member_monthly_goals', + ('guildid', 'userid', 'monthid', 'study_goal', 'task_goal'), + ('guildid', 'userid', 'monthid'), + cache=TTLCache(5000, 60 * 60 * 24), + attach_as='monthly_goals' +) + +monthly_tasks = Table('member_monthly_goal_tasks') diff --git a/bot/modules/stats/setprofile.py b/bot/modules/stats/setprofile.py index 8eeafffd..9618b388 100644 --- a/bot/modules/stats/setprofile.py +++ b/bot/modules/stats/setprofile.py @@ -167,7 +167,7 @@ async def cmd_setprofile(ctx, flags): # Ack with user embed = discord.Embed( colour=discord.Colour.green(), - description="Profile tags updated!" + title="Profile tags updated!" ) embed.add_field( name="New tags", @@ -175,10 +175,14 @@ async def cmd_setprofile(ctx, flags): ) if deleted_rows: embed.add_field( - name="Previous tags", + name="Replaced tags", value='\n'.join(row['tag'].upper() for row in deleted_rows), inline=False ) + if len(to_add) == 1: + embed.set_footer( + text=f"TIP: Add multiple tags with {ctx.best_prefix}setprofile tag1, tag2, ..." + ) await ctx.reply(embed=embed) else: # No input was provided diff --git a/data/migration/v6-v7/migration.sql b/data/migration/v6-v7/migration.sql index 3d4db031..905f4452 100644 --- a/data/migration/v6-v7/migration.sql +++ b/data/migration/v6-v7/migration.sql @@ -21,6 +21,56 @@ CREATE TABLE member_profile_tags( _timestamp TIMESTAMPTZ DEFAULT now(), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ); +CREATE INDEX member_profile_tags_members ON member_profile_tags (guildid, userid); +-- New member weekly and monthly goals +CREATE TABLE member_weekly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, -- Epoch time of the start of the UTC week + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, weekid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goals_members ON member_weekly_goals (guildid, userid); + +CREATE TABLE member_weekly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (weekid, guildid, userid) REFERENCES member_weekly_goals (weekid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goal_tasks_members_weekly ON member_weekly_goal_tasks (guildid, userid, weekid); + +CREATE TABLE member_monthly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, -- Epoch time of the start of the UTC month + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, monthid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goals_members ON member_monthly_goals (guildid, userid); + +CREATE TABLE member_monthly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (monthid, guildid, userid) REFERENCES member_monthly_goals (monthid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_tasks (guildid, userid, monthid); + INSERT INTO VersionHistory (version, author) VALUES (7, 'v6-v7 migration'); diff --git a/data/schema.sql b/data/schema.sql index 5edb3add..92f40c2f 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -692,9 +692,57 @@ CREATE TABLE member_profile_tags( _timestamp TIMESTAMPTZ DEFAULT now(), FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ); +CREATE INDEX member_profile_tags_members ON member_profile_tags (guildid, userid); -- }}} -- Member goals {{{ +CREATE TABLE member_weekly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, -- Epoch time of the start of the UTC week + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, weekid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goals_members ON member_weekly_goals (guildid, userid); + +CREATE TABLE member_weekly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + weekid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (weekid, guildid, userid) REFERENCES member_weekly_goals (weekid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_weekly_goal_tasks_members_weekly ON member_weekly_goal_tasks (guildid, userid, weekid); + +CREATE TABLE member_monthly_goals( + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, -- Epoch time of the start of the UTC month + study_goal INTEGER, + task_goal INTEGER, + _timestamp TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (guildid, userid, monthid), + FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goals_members ON member_monthly_goals (guildid, userid); + +CREATE TABLE member_monthly_goal_tasks( + taskid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + monthid INTEGER NOT NULL, + content TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now(), + FOREIGN KEY (monthid, guildid, userid) REFERENCES member_monthly_goals (monthid, guildid, userid) ON DELETE CASCADE +); +CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_tasks (guildid, userid, monthid); -- }}} From ad723fe6a37bf6fecc1d1aa8699fd69597320dfc Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 13:57:11 +0200 Subject: [PATCH 51/56] feature (utils): Add a ratelimit implementation. --- bot/utils/ratelimits.py | 92 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 bot/utils/ratelimits.py diff --git a/bot/utils/ratelimits.py b/bot/utils/ratelimits.py new file mode 100644 index 00000000..545e5c53 --- /dev/null +++ b/bot/utils/ratelimits.py @@ -0,0 +1,92 @@ +import time +from cmdClient.lib import SafeCancellation + +from cachetools import TTLCache + + +class BucketFull(Exception): + """ + Throw when a requested Bucket is already full + """ + pass + + +class BucketOverFull(BucketFull): + """ + Throw when a requested Bucket is overfull + """ + pass + + +class Bucket: + __slots__ = ('max_level', 'empty_time', 'leak_rate', '_level', '_last_checked', '_last_full') + + def __init__(self, max_level, empty_time): + self.max_level = max_level + self.empty_time = empty_time + self.leak_rate = max_level / empty_time + + self._level = 0 + self._last_checked = time.time() + + self._last_full = False + + @property + def overfull(self): + self._leak() + return self._level > self.max_level + + def _leak(self): + if self._level: + elapsed = time.time() - self._last_checked + self._level = max(0, self._level - (elapsed * self.leak_rate)) + + self._last_checked = time.time() + + def request(self): + self._leak() + if self._level + 1 > self.max_level + 1: + raise BucketOverFull + elif self._level + 1 > self.max_level: + self._level += 1 + if self._last_full: + raise BucketOverFull + else: + self._last_full = True + raise BucketFull + else: + self._last_full = False + self._level += 1 + + +class RateLimit: + def __init__(self, max_level, empty_time, error=None, cache=TTLCache(1000, 60 * 60)): + self.max_level = max_level + self.empty_time = empty_time + + self.error = error or "Too many requests, please slow down!" + self.buckets = cache + + def request_for(self, key): + if not (bucket := self.buckets.get(key, None)): + bucket = self.buckets[key] = Bucket(self.max_level, self.empty_time) + + try: + bucket.request() + except BucketOverFull: + raise SafeCancellation(details="Bucket overflow") + except BucketFull: + raise SafeCancellation(self.error, details="Bucket full") + + def ward(self, member=True, key=None): + """ + Command ratelimit decorator. + """ + key = key or ((lambda ctx: (ctx.guild.id, ctx.author.id)) if member else (lambda ctx: ctx.author.id)) + + def decorator(func): + async def wrapper(ctx, *args, **kwargs): + self.request_for(key(ctx)) + return await func(ctx, *args, **kwargs) + return wrapper + return decorator From e0c8993167830cfe24b14c49a4c59b4bf37baaa4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 14:01:04 +0200 Subject: [PATCH 52/56] (goals): Add missing goals command. --- bot/modules/stats/goals.py | 326 +++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 bot/modules/stats/goals.py diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py new file mode 100644 index 00000000..68ee4897 --- /dev/null +++ b/bot/modules/stats/goals.py @@ -0,0 +1,326 @@ +""" +Weekly and Monthly goal display and edit interface. +""" +from enum import Enum +import discord + +from cmdClient.checks import in_guild +from cmdClient.lib import SafeCancellation + +from utils.lib import parse_ranges + +from .module import module +from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks + + +MAX_LENGTH = 200 +MAX_TASKS = 5 + + +class GoalType(Enum): + WEEKLY = 0 + MONTHLY = 1 + + +def index_range_parser(userstr, max): + try: + indexes = parse_ranges(userstr) + except SafeCancellation: + raise SafeCancellation( + "Couldn't parse the provided task ids! " + "Please list the task numbers or ranges separated by a comma, e.g. `0, 2-4`." + ) from None + + return [index for index in indexes if index <= max] + + +@module.cmd( + "weeklygoals", + group="Statistics", + desc="Set your weekly goals and view your progress!", + aliases=('weeklygoal',), + flags=('study=', 'tasks=') +) +@in_guild() +async def cmd_weeklygoals(ctx, flags): + """ + Usage``: + {prefix}weeklygoals [--study ] [--tasks ] + {prefix}weeklygoals add + {prefix}weeklygoals edit + {prefix}weeklygoals check + {prefix}weeklygoals remove + Description: + Set yourself up to `5` goals for this week and keep yourself accountable! + Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. + You can also add multiple tasks at once by writing them on multiple lines. + + You can also track your progress towards a number of hours studied with `--study`, \ + and aim for a number of tasks completed with `--tasks`. + + Run the command with no arguments or check your profile to see your progress! + Examples``: + {prefix}weeklygoals add Read chapters 1 to 10. + {prefix}weeklygoals check 1 + {prefix}weeklygoals --study 48h --tasks 60 + """ + await goals_command(ctx, flags, GoalType.WEEKLY) + + +@module.cmd( + "monthlygoals", + group="Statistics", + desc="Set your monthly goals and view your progress!", + aliases=('monthlygoal',), + flags=('study=', 'tasks=') +) +@in_guild() +async def cmd_monthlygoals(ctx, flags): + """ + Usage``: + {prefix}monthlygoals [--study ] [--tasks ] + {prefix}monthlygoals add + {prefix}monthlygoals edit + {prefix}monthlygoals check + {prefix}monthlygoals uncheck + {prefix}monthlygoals remove + Description: + Set yourself up to `5` goals for this month and keep yourself accountable! + Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. + You can also add multiple tasks at once by writing them on multiple lines. + + You can also track your progress towards a number of hours studied with `--study`, \ + and aim for a number of tasks completed with `--tasks`. + + Run the command with no arguments or check your profile to see your progress! + Examples``: + {prefix}monthlygoals add Read chapters 1 to 10. + {prefix}monthlygoals check 1 + {prefix}monthlygoals --study 180h --tasks 60 + """ + await goals_command(ctx, flags, GoalType.MONTHLY) + + +async def goals_command(ctx, flags, goal_type): + prefix = ctx.best_prefix + if goal_type == GoalType.WEEKLY: + name = 'week' + goal_table = weekly_goals + task_table = weekly_tasks + rowkey = 'weekid' + rowid = ctx.alion.week_timestamp + + tasklist = task_table.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + weekid=rowid + ) + + max_time = 7 * 16 + else: + name = 'month' + goal_table = monthly_goals + task_table = monthly_tasks + rowid = ctx.alion.month_timestamp + rowkey = 'monthid' + + tasklist = task_table.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + monthid=rowid + ) + + max_time = 31 * 16 + + # We ensured the `lion` existed with `ctx.alion` above + # This also ensures a new tasklist can reference the period member goal key + # TODO: Should creation copy the previous existing week? + goal_row = goal_table.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) + + if flags['study']: + # Set study hour goal + time = flags['study'].lower().strip('h ') + if not time or not time.isdigit(): + return await ctx.error_reply( + f"Please provide your {name}ly study goal in hours!\n" + f"For example, `{prefix}{ctx.alias} --study 48h`" + ) + hours = int(time) + if hours > max_time: + return await ctx.error_reply( + "You can't set your goal this high! Please rest and keep a healthy lifestyle." + ) + + goal_row.study_goal = hours + + if flags['tasks']: + # Set tasks completed goal + count = flags['tasks'] + if not count or not count.isdigit(): + return await ctx.error_reply( + f"Please provide the number of tasks you want to complete this {name}!\n" + f"For example, `{prefix}{ctx.alias} --tasks 300`" + ) + goal_row.task_goal = int(count) + + if ctx.args: + # If there are arguments, assume task/goal management + # Extract the command if it exists, assume add operation if it doesn't + splits = ctx.args.split(maxsplit=1) + cmd = splits[0].lower().strip() + args = splits[1].strip() if len(splits) > 1 else '' + + if cmd in ('check', 'done', 'complete'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} check `\n" + f"**Example:**`{prefix}{ctx.alias} check 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Check the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.update_where( + {'completed': True}, + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd in ('uncheck', 'undone', 'uncomplete'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} uncheck `\n" + f"**Example:**`{prefix}{ctx.alias} uncheck 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Check the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.update_where( + {'completed': False}, + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd in ('remove', 'delete', '-', 'rm'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} remove `\n" + f"**Example:**`{prefix}{ctx.alias} remove 0, 2-4`" + ) + if (indexes := index_range_parser(args, len(tasklist) - 1)): + # Delete the given indexes + # If there are no valid indexes given, just do nothing and fall out to showing the goals + task_table.delete_where( + taskid=[tasklist[index]['taskid'] for index in indexes] + ) + elif cmd == 'edit': + if not args or len(splits := args.split(maxsplit=1)) < 2 or not splits[0].isdigit(): + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} edit `\n" + f"**Example:**`{prefix}{ctx.alias} edit 2 Fix the scond task`" + ) + index = int(splits[0]) + new_content = splits[1].strip() + + if index >= len(tasklist): + return await ctx.error_reply( + f"Task `{index}` doesn't exist to edit!" + ) + + if len(new_content) > MAX_LENGTH: + return await ctx.error_reply( + f"Please keep your goals under `{MAX_LENGTH}` characters long." + ) + + # Passed all checks, edit task + task_table.update_were( + {'content': new_content}, + taskid=tasklist[index]['taskid'] + ) + else: + # Extract the tasks to add + if cmd in ('add', '+'): + if not args: + # Show subcommand usage + return await ctx.error_reply( + f"**Usage:**`{prefix}{ctx.alias} [add] `\n" + f"**Example:**`{prefix}{ctx.alias} add Read the Studylion help pages.`" + ) + else: + args = ctx.args + tasks = args.splitlines() + + # Check count + if len(tasklist) + len(tasks) > MAX_TASKS: + return await ctx.error_reply( + f"You can have at most **{MAX_TASKS}** {name}ly goals!" + ) + + # Check length + if any(len(task) > MAX_LENGTH for task in tasks): + return await ctx.error_reply( + f"Please keep your goals under `{MAX_LENGTH}` characters long." + ) + + # We passed the checks, add the tasks + to_insert = [ + (ctx.guild.id, ctx.author.id, rowid, task) + for task in tasks + ] + task_table.insert_many( + *to_insert, + insert_keys=('guildid', 'userid', rowkey, 'content') + ) + elif not any((goal_row.study_goal, goal_row.task_goal, tasklist)): + # The user hasn't set any goals for this time period + # Prompt them with information about how to set a goal + embed = discord.Embed( + colour=discord.Colour.orange(), + title=f"**You haven't set any goals for this {name} yet! Try the following:**\n" + ) + embed.add_field( + name="Aim for a number of study hours with", + value=f"`{prefix}{ctx.alias} --study 48h`" + ) + embed.add_field( + name="Aim for a number of tasks completed with", + value=f"`{prefix}{ctx.alias} --tasks 300`", + inline=False + ) + embed.add_field( + name=f"Set up to 5 custom goals for the {name}!", + value=( + f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n" + f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n" + f"`{prefix}{ctx.alias} done 0, 1, 3-4`\n" + f"`{prefix}{ctx.alias} delete 2-4`" + ), + inline=False + ) + return await ctx.reply(embed=embed) + + # Show the goals + if goal_type == GoalType.WEEKLY: + await display_weekly_goals_for(ctx) + else: + await display_monthly_goals_for(ctx) + + +async def display_weekly_goals_for(ctx): + """ + Display the user's weekly goal summary and progress towards them + TODO: Currently a stub, since the system is overidden by the GUI plugin + """ + # Collect data + lion = ctx.alion + rowid = lion.week_timestamp + goals = weekly_goals.fetch_or_create((ctx.guild.id, ctx.author.id, rowid)) + tasklist = weekly_tasks.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + weekid=rowid + ) + ... + + +async def display_monthly_goals_for(ctx): + ... From 7acf7476a48eeeb9b6ac4eee2b213bd8a40a1b02 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:39:23 +0200 Subject: [PATCH 53/56] (goals): Update limits. --- bot/modules/stats/goals.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 68ee4897..39b8c254 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -14,7 +14,7 @@ from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks MAX_LENGTH = 200 -MAX_TASKS = 5 +MAX_TASKS = 10 class GoalType(Enum): @@ -161,6 +161,10 @@ async def goals_command(ctx, flags, goal_type): f"Please provide the number of tasks you want to complete this {name}!\n" f"For example, `{prefix}{ctx.alias} --tasks 300`" ) + if int(count) > 2048: + return await ctx.error_reply( + "Your task goal is too high!" + ) goal_row.task_goal = int(count) if ctx.args: From d0e987d0b137a6d0e349f4e2cb9c6bc25f16cbbf Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:46:16 +0200 Subject: [PATCH 54/56] fix (goals): Order retrieved tasklist. --- bot/modules/stats/goals.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 39b8c254..6cb716f7 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -113,7 +113,8 @@ async def goals_command(ctx, flags, goal_type): tasklist = task_table.select_where( guildid=ctx.guild.id, userid=ctx.author.id, - weekid=rowid + weekid=rowid, + _extra="ORDER BY taskid ASC" ) max_time = 7 * 16 @@ -127,7 +128,8 @@ async def goals_command(ctx, flags, goal_type): tasklist = task_table.select_where( guildid=ctx.guild.id, userid=ctx.author.id, - monthid=rowid + monthid=rowid, + _extra="ORDER BY taskid ASC" ) max_time = 31 * 16 From 7e3f1a2fbb9e19b4081898f8d8ac38ae8634a8fb Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 22:51:04 +0200 Subject: [PATCH 55/56] fix (goals): Fix reference to old limits. --- bot/modules/stats/goals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index 6cb716f7..a3a03ae8 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -51,7 +51,7 @@ async def cmd_weeklygoals(ctx, flags): {prefix}weeklygoals check {prefix}weeklygoals remove Description: - Set yourself up to `5` goals for this week and keep yourself accountable! + Set yourself up to `10` goals for this week and keep yourself accountable! Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. You can also add multiple tasks at once by writing them on multiple lines. @@ -85,7 +85,7 @@ async def cmd_monthlygoals(ctx, flags): {prefix}monthlygoals uncheck {prefix}monthlygoals remove Description: - Set yourself up to `5` goals for this month and keep yourself accountable! + Set yourself up to `10` goals for this month and keep yourself accountable! Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`. You can also add multiple tasks at once by writing them on multiple lines. @@ -293,7 +293,7 @@ async def goals_command(ctx, flags, goal_type): inline=False ) embed.add_field( - name=f"Set up to 5 custom goals for the {name}!", + name=f"Set up to 10 custom goals for the {name}!", value=( f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n" f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n" From 5f3e765b51c5b47bc7f96622f2a484ee25487904 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 30 Dec 2021 23:06:46 +0200 Subject: [PATCH 56/56] fix (goals): Fix typo. --- bot/modules/stats/goals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/stats/goals.py b/bot/modules/stats/goals.py index a3a03ae8..cec8f911 100644 --- a/bot/modules/stats/goals.py +++ b/bot/modules/stats/goals.py @@ -238,7 +238,7 @@ async def goals_command(ctx, flags, goal_type): ) # Passed all checks, edit task - task_table.update_were( + task_table.update_where( {'content': new_content}, taskid=tasklist[index]['taskid'] )