From bad9071e82cdbb417fdfd9b6969212a18c3cc438 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 6 Jan 2022 09:31:37 +0200 Subject: [PATCH 01/21] (routine): Update required data version. --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index e5eb789a..4594570a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 6 +DATA_VERSION = 7 From 08efbb15b640630668a1b2df7d2dfcd0917bc5d4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 6 Jan 2022 09:32:25 +0200 Subject: [PATCH 02/21] fix (rroles): Sign error in expiry. --- bot/modules/guild_admin/reaction_roles/expiry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/guild_admin/reaction_roles/expiry.py b/bot/modules/guild_admin/reaction_roles/expiry.py index f928cd74..7de2c592 100644 --- a/bot/modules/guild_admin/reaction_roles/expiry.py +++ b/bot/modules/guild_admin/reaction_roles/expiry.py @@ -126,7 +126,7 @@ async def _expiry_tracker(client): while True: try: key = _next() - diff = utc_now().timestamp() - _expiring[key] if key else None + diff = _expiring[key] - utc_now().timestamp() if key else None await asyncio.wait_for(_wakeup_event.wait(), timeout=diff) except asyncio.TimeoutError: # Timeout means next doesn't exist or is ready to expire From 223d45416d0f5e3a7bde256a27f0146c1b989e59 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 6 Jan 2022 09:33:14 +0200 Subject: [PATCH 03/21] fix (utils): Typo in `embed_reply` error handling. --- bot/utils/ctx_addons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/ctx_addons.py b/bot/utils/ctx_addons.py index 9697eeec..1a01139c 100644 --- a/bot/utils/ctx_addons.py +++ b/bot/utils/ctx_addons.py @@ -20,7 +20,7 @@ async def embed_reply(ctx, desc, colour=discord.Colour.orange(), **kwargs): try: return await ctx.reply(embed=embed, reference=ctx.msg.to_reference(fail_if_not_exists=False)) except discord.Forbidden: - if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_mssages: + if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages: await ctx.reply("Command failed, I don't have permission to send embeds in this channel!") raise SafeCancellation From 543187756975574703755f1818e2b92807458d0a Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 8 Jan 2022 02:58:16 +0200 Subject: [PATCH 04/21] feature (timer): Core pomodoro system. --- bot/modules/study/timers/Timer.py | 408 +++++++++++++++++++++++++++ bot/modules/study/timers/__init__.py | 2 + bot/modules/study/timers/commands.py | 346 +++++++++++++++++++++++ bot/modules/study/timers/data.py | 14 + data/schema.sql | 15 + 5 files changed, 785 insertions(+) create mode 100644 bot/modules/study/timers/Timer.py create mode 100644 bot/modules/study/timers/commands.py create mode 100644 bot/modules/study/timers/data.py diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py new file mode 100644 index 00000000..8946db56 --- /dev/null +++ b/bot/modules/study/timers/Timer.py @@ -0,0 +1,408 @@ +import asyncio +import discord +from collections import namedtuple +from datetime import timedelta + +from utils.lib import utc_now +from meta import client +from settings import GuildSettings +from data.conditions import THIS_SHARD + + +from ..module import module + +from .data import timers as timer_table + + +Stage = namedtuple('Stage', ['name', 'start', 'duration', 'end']) + + +class Timer: + timers = {} # channelid -> Timer + + def __init__(self, channelid): + self.channelid = channelid + self.last_seen = { + } # Memberid -> timestamps + + self.reaction_message = None + + self._state = None + self._last_voice_update = None + + self._voice_update_task = None + self._run_task = None + self._runloop_task = None + + @classmethod + def create(cls, channel, focus_length, break_length, **kwargs): + timer_table.create_row( + channelid=channel.id, + guildid=channel.guild.id, + focus_length=focus_length, + break_length=break_length, + last_started=kwargs.pop('last_started', utc_now()), + **kwargs + ) + return cls(channel.id) + + @classmethod + def fetch_timer(cls, channelid): + return cls.timers.get(channelid, None) + + @classmethod + def fetch_guild_timers(cls, guildid): + timers = [] + guild = client.get_guild(guildid) + if guild: + for channel in guild.voice_channels: + if (timer := cls.timers.get(channel.id, None)): + timers.append(timer) + + return timers + + @property + def data(self): + return timer_table.fetch(self.channelid) + + @property + def focus_length(self): + return self.data.focus_length + + @property + def break_length(self): + return self.data.break_length + + @property + def inactivity_threshold(self): + return self.data.inactivity_threshold or 3 + + @property + def current_stage(self): + if (last_start := self.data.last_started) is None: + # Timer hasn't been started + return None + now = utc_now() + diff = (now - last_start).total_seconds() + diff %= (self.focus_length + self.break_length) + if diff > self.focus_length: + return Stage( + 'BREAK', + now - timedelta(seconds=(diff - self.focus_length)), + self.break_length, + now + timedelta(seconds=(- diff + self.focus_length + self.break_length)) + ) + else: + return Stage( + 'FOCUS', + now - timedelta(seconds=diff), + self.focus_length, + now + timedelta(seconds=(self.focus_length - diff)) + ) + + @property + def guild(self): + return client.get_guild(self.data.guildid) + + @property + def channel(self): + return client.get_channel(self.channelid) + + @property + def text_channel(self): + return GuildSettings(self.data.guildid).alert_channel.value + + @property + def members(self): + if (channel := self.channel): + return channel.members + else: + return [] + + @property + def channel_name(self): + """ + Current name for the voice channel + """ + stage = self.current_stage + name_format = self.data.channel_name or "{remaining} -- {name}" + return name_format.replace( + '{remaining}', "{:02}:{:02}".format( + int((stage.end - utc_now()).total_seconds() // 60), + int((stage.end - utc_now()).total_seconds() % 60), + ) + ).replace( + '{members}', str(len(self.channel.members)) + ).replace( + '{name}', self.data.pretty_name or "WORK ROOM" + ) + + async def notify_change_stage(self, old_stage, new_stage): + # Kick people if they need kicking + to_warn = [] + to_kick = [] + warn_threshold = (self.inactivity_threshold - 1) * (self.break_length + self.focus_length) + kick_threshold = self.inactivity_threshold * (self.break_length + self.focus_length) + for member in self.members: + if member.id in self.last_seen: + diff = (utc_now() - self.last_seen[member.id]).total_seconds() + if diff >= kick_threshold: + to_kick.append(member) + elif diff > warn_threshold: + to_warn.append(member) + else: + # Shouldn't really happen, but + self.last_seen[member.id] = utc_now() + + content = [] + if to_kick: + # Do kick + await asyncio.gather( + *(member.edit(voice_channel=None) for member in to_kick), + return_exceptions=True + ) + kick_string = ( + "**Kicked due to inactivity:** {}".format(', '.join(member.mention for member in to_kick)) + ) + content.append(kick_string) + + if to_warn: + warn_string = ( + "**Please react to avoid being kicked:** {}".format( + ', '.join(member.mention for member in to_warn) + ) + ) + content.append(warn_string) + + if self.text_channel and self.members: + # Send status image, add reaction + self.reaction_message = await self.text_channel.send( + content='\n'.join(content), + **(await self.status()) + ) + await self.reaction_message.add_reaction('✅') + + # Ping people + members = self.members + blocks = [ + ''.join(member.mention for member in members[i:i+90]) + for i in range(0, len(members), 90) + ] + await asyncio.gather( + *(self.text_channel.send(block, delete_after=0.5) for block in blocks), + return_exceptions=True + ) + # TODO: DM task if anyone has notifications on + + # Mute or unmute everyone in the channel as needed + # Not possible, due to Discord restrictions + # overwrite = self.channel.overwrites_for(self.channel.guild.default_role) + # overwrite.speak = (new_stage.name == 'BREAK') + # try: + # await self.channel.set_permissions( + # self.channel.guild.default_role, + # overwrite=overwrite + # ) + # except discord.HTTPException: + # pass + + # Update channel name + asyncio.create_task(self._update_channel_name()) + + # Run the notify hook + await self.notify_hook(old_stage, new_stage) + + async def notify_hook(self, old_stage, new_stage): + """ + May be overridden to provide custom actions during notification. + For example, for voice alerts. + """ + ... + + async def _update_channel_name(self): + # Attempt to update the voice channel name + # Ensures that only one update is pending at any time + # Attempts to wait until the next viable channel update + if self._voice_update_task: + self._voice_update_task.cancel() + + if self.channel.name == self.channel_name: + return + + if self._last_voice_update: + to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds() + if to_wait > 0: + self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait)) + try: + await self._voice_update_task + except asyncio.CancelledError: + return + self._voice_update_task = asyncio.create_task( + self.channel.edit(name=self.channel_name) + ) + try: + await self._voice_update_task + self._last_voice_update = utc_now() + except asyncio.CancelledError: + return + + async def status(self): + """ + Returns argument dictionary compatible with `discord.Channel.send`. + """ + # Generate status message + stage = self.current_stage + stage_str = "**{}** minutes focus with **{}** minutes break".format( + self.focus_length // 60, + self.break_length // 60 + ) + remaining = (stage.end - utc_now()).total_seconds() + + memberstr = ', '.join(member.mention for member in self.members[:20]) + if len(self.members) > 20: + memberstr += '...' + + description = ( + ("{}: {}\n" + "Currently in `{}`, with `{:02}:{:02}` remaining.\n" + "{}").format( + self.channel.mention, + stage_str, + stage.name, + int(remaining // 3600), + int((remaining // 60) % 60), + memberstr + ) + ) + embed = discord.Embed( + colour=discord.Colour.orange(), + description=description + ) + return {'embed': embed} + + async def update_last_status(self): + """ + Update the last posted status message, if it exists. + """ + args = await self.status() + repost = True + if self.reaction_message: + try: + await self.reaction_message.edit(**args) + except discord.HTTPException: + pass + else: + repost = False + + if repost: + try: + self.reaction_message = await self.text_channel.send(**args) + await self.reaction_message.add_reaction('✅') + except discord.HTTPException: + pass + + return + + async def destroy(self): + """ + Remove the timer. + """ + # Cancel the loop + if self._run_task: + self._run_task.cancel() + + # Delete the reaction message + if self.reaction_message: + try: + await self.reaction_message.delete() + except discord.HTTPException: + pass + + # Remove the timer from data + timer_table.delete_where(channelid=self.channelid) + + async def run(self): + """ + Runloop + """ + timer = self.timers.pop(self.channelid, None) + if timer and timer._run_task: + timer._run_task.cancel() + self.timers[self.channelid] = self + + if not self.data.last_started: + self.data.last_started = utc_now() + asyncio.create_task(self.notify_change_stage(None, self.current_stage)) + + while True: + stage = self._state = self.current_stage + to_next_stage = (stage.end - utc_now()).total_seconds() + + if to_next_stage > 10 * 60: + time_to_sleep = 5 * 60 + else: + time_to_sleep = to_next_stage + + self._run_task = asyncio.create_task(asyncio.sleep(time_to_sleep)) + try: + await self._run_task + except asyncio.CancelledError: + break + + if self._state.end < utc_now(): + asyncio.create_task(self.notify_change_stage(self._state, self.current_stage)) + else: + asyncio.create_task(self._update_channel_name()) + + def runloop(self): + self._runloop_task = asyncio.create_task(self.run()) + + +# Loading logic +@module.launch_task +async def load_timers(client): + timer_rows = timer_table.fetch_rows_where( + guildid=THIS_SHARD + ) + count = 0 + for row in timer_rows: + if client.get_channel(row.channelid): + # Channel exists + # Create the timer + timer = Timer(row.channelid) + + # Populate the members + timer.last_seen = { + member.id: utc_now() + for member in timer.members + } + + # Start the timer + timer.runloop() + count += 1 + + client.log( + "Loaded and start '{}' timers!".format(count), + context="TIMERS" + ) + + +# Hooks +@client.add_after_event('raw_reaction_add') +async def reaction_tracker(client, payload): + if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice: + if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)): + if payload.message_id == timer.reaction_message.id: + timer.last_seen[payload.member.id] = utc_now() + + +@client.add_after_event('voice_state_update') +async def touch_member(client, member, before, after): + if not member.bot and after.channel != before.channel: + if after.channel and (timer := Timer.fetch_timer(after.channel.id)): + timer.last_seen[member.id] = utc_now() + await timer.update_last_status() + + if before.channel and (timer := Timer.fetch_timer(before.channel.id)): + timer.last_seen.pop(member.id) + await timer.update_last_status() diff --git a/bot/modules/study/timers/__init__.py b/bot/modules/study/timers/__init__.py index e69de29b..fb9b1bc2 100644 --- a/bot/modules/study/timers/__init__.py +++ b/bot/modules/study/timers/__init__.py @@ -0,0 +1,2 @@ +from .Timer import Timer +from . import commands diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py new file mode 100644 index 00000000..022bf96c --- /dev/null +++ b/bot/modules/study/timers/commands.py @@ -0,0 +1,346 @@ +import discord +from cmdClient import Context +from cmdClient.checks import in_guild +from cmdClient.lib import SafeCancellation + +from datetime import timedelta + +from wards import guild_admin +from utils.lib import utc_now, tick + +from ..module import module + +from .Timer import Timer + + +config_flags = ('name==', 'threshold=', 'channelname==') + +@module.cmd( + "timer", + group="Productivity", + desc="Display your study room pomodoro timer.", + flags=config_flags +) +@in_guild() +async def cmd_timer(ctx: Context, flags): + """ + Usage``: + {prefix}timer + Description: + Display your current study room timer status. + If you aren't in a study room, instead shows a list of timers you can join. + """ + channel = ctx.author.voice.channel if ctx.author.voice else None + if ctx.args: + if len(splits := ctx.args.split()) > 1: + # Multiple arguments provided + # Assume configuration attempt + return await _pomo_admin(ctx, flags) + else: + # Single argument provided, assume channel reference + channel = await ctx.find_channel( + ctx.args, + interactive=True, + chan_type=discord.ChannelType.voice, + ) + if channel is None: + return + if channel is None: + # Author is not in a voice channel, and they did not select a channel + # Display the server timers they can see + timers = Timer.fetch_guild_timers(ctx.guild.id) + timers = [ + timer for timer in timers + if timer.channel and timer.channel.permissions_for(ctx.author).view_channel + ] + if not timers: + return await ctx.error_reply( + "There are no available timers!" + ) + # Build a summary list + timer_strings = [] + for timer in timers: + stage = timer.current_stage + stage_str = "**{}** minutes focus with **{}** minutes break".format( + timer.focus_length // 60, timer.break_length // 60 + ) + remaining = (stage.end - utc_now()).total_seconds() + + memberstr = ', '.join(member.mention for member in timer.members[:20]) + if len(timer.members) > 20: + memberstr += '...' + + timer_strings.append( + ("{}: {}\n" + "Currently in `{}`, with `{:02}:{:02}` remaining.\n" + "{}").format( + timer.channel.mention, + stage_str, + stage.name, + int(remaining // 3600), + int((remaining // 60) % 60), + memberstr + ) + ) + + blocks = [ + '\n\n'.join(timer_strings[i:i+4]) + for i in range(0, len(timer_strings), 4) + ] + embeds = [ + discord.Embed( + title="Pomodoro Timers", + description=block, + colour=discord.Colour.orange() + ) + for block in blocks + ] + await ctx.pager(embeds) + else: + # We have a channel + # Get the associated timer + timer = Timer.fetch_timer(channel.id) + if timer is None: + # No timer in this channel + return await ctx.error_reply( + f"{channel.mention} doesn't have a timer!" + ) + else: + # We have a timer + # Show the timer status + await ctx.reply(**await timer.status()) + + +@module.cmd( + "pomodoro", + group="Guild Admin", + desc="Create and modify the voice channel pomodoro timers.", + flags=config_flags +) +async def ctx_pomodoro(ctx, flags): + """ + Usage``: + {prefix}pomodoro [channelid] , [channel name] [options] + {prefix}pomodoro [channelid] [options] + {prefix}pomodoro [channelid] delete + Description: + ... + Options:: + --name: The name of the timer as shown in the timer status. + --channelname: The voice channel name template. + --threshold: How many work+break sessions before a user is removed. + Examples``: + {prefix}pomodoro 50, 10 + ... + """ + await _pomo_admin(ctx, flags) + + +async def _pomo_admin(ctx, flags): + # Extract target channel + if ctx.author.voice: + channel = ctx.author.voice.channel + else: + channel = None + + args = ctx.args + if ctx.args: + splits = ctx.args.split(maxsplit=1) + if splits[0].strip('#<>').isdigit() or len(splits[0]) > 10: + # Assume first argument is a channel specifier + channel = await ctx.find_channel( + splits[0], interactive=True, chan_type=discord.ChannelType.voice + ) + if not channel: + # Invalid channel provided + # find_channel already gave a message, just return silently + return + args = splits[1] if len(splits) > 1 else "" + + if not args and not any(flags.values()): + # No arguments given to the `pomodoro` command. + # TODO: If we have a channel, replace this with timer setting information + return await ctx.error_reply( + f"See `{ctx.best_prefix}help pomodoro` for usage and examples." + ) + + if not channel: + return await ctx.error_reply( + f"No channel specified!\n" + "Please join a voice channel or pass the id as the first argument.\n" + f"See `{ctx.best_prefix}help pomodoro` for more usage information." + ) + + # Now we have a channel and configuration arguments + # Next check the user has authority to modify the timer + if not await guild_admin.run(ctx): + # TODO: The channel is a room they own? + return await ctx.error_reply( + "You need to be a guild admin to set up the pomodoro timers!" + ) + + # Get the associated timer, if it exists + timer = Timer.fetch_timer(channel.id) + + # Parse required action + if args.lower() == 'delete': + if timer: + await timer.destroy() + await ctx.embed_reply( + "Destroyed the timer in {}.".format(channel.mention) + ) + else: + await ctx.error_reply( + "{} doesn't have a timer to delete!".format(channel.mention) + ) + elif args or timer: + if args: + # Any provided arguments should be for setting up a new timer pattern + # First validate input + try: + # Ensure no trailing commas + args = args.strip(',') + if ',' not in args: + raise SafeCancellation("Couldn't parse work and break times!") + + timesplits = args.split(',', maxsplit=1) + if not timesplits[0].isdigit() or len(timesplits[0]) > 3: + raise SafeCancellation(f"Couldn't parse the provided work period length `{timesplits[0]}`.") + + breaksplits = timesplits[1].split(maxsplit=1) + if not breaksplits[0].isdigit() or len(breaksplits[0]) > 3: + raise SafeCancellation(f"Couldn't parse the provided break period length `{breaksplits[0]}`.") + except SafeCancellation as e: + usage = discord.Embed( + title="Couldn't understand arguments!", + colour=discord.Colour.red() + ) + usage.add_field( + name="Usage", + value=( + f"`{ctx.best_prefix}{ctx.alias} [channelid] , [channel name template]" + ) + ) + usage.add_field( + name="Examples", + value=( + f"`{ctx.best_prefix}{ctx.alias} 50, 10`\n" + f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10`\n" + f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10 {{remaining}} - {channel.name}`\n" + ), + inline=False + ) + usage.set_footer( + text=f"For detailed usage and examples see {ctx.best_prefix}help pomodoro" + ) + if e.msg: + usage.description = e.msg + return ctx.reply(embed=usage) + + # Input validation complete, assign values + focus_length = int(timesplits[0]) + break_length = int(breaksplits[0]) + channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None + + # Create or update the timer + if not timer: + # Create timer + # First check permissions + if not channel.permissions_for(ctx.guild.me).send_messages: + embed = discord.Embed( + title="Could not create timer!", + description=f"I do not have sufficient guild permissions to join {channel.mention}!", + colour=discord.Colour.red() + ) + return await ctx.reply(embed=embed) + + # Create timer + timer = Timer.create( + channel, + focus_length * 60, + break_length * 60, + channel_name=channelname or None, + pretty_name=channel.name + ) + timer.last_seen = { + member.id: utc_now() + for member in timer.members + } + timer.runloop() + + # Post a new status message + await timer.update_last_status() + + await ctx.embed_reply( + f"Started a new `{focus_length}, {break_length}` pomodoro timer in {channel.mention}." + ) + else: + # Update timer and restart + stage = timer.current_stage + + timer.last_seen = { + member.id: utc_now() + for member in timer.members + } + + with timer.data.batch_update(): + timer.data.focus_length = focus_length * 60 + timer.data.break_length = break_length * 60 + timer.data.last_started = utc_now() + if channelname: + timer.data.channel_name = channelname + + await timer.notify_change_stage(stage, timer.current_stage) + timer.runloop() + + await ctx.embed_reply( + f"Restarted the pomodoro timer in {channel.mention} as `{focus_length}, {break_length}`." + ) + + to_set = [] + if flags['name']: + # Handle name update + to_set.append(( + 'pretty_name', + flags['name'], + f"The timer will now appear as `{flags['name']}` in the status." + )) + if flags['threshold']: + # Handle threshold update + if not flags['threshold'].isdigit(): + return await ctx.error_reply( + "The provided threshold must be a number!" + ) + to_set.append(( + 'inactivity_threshold', + int(flags['threshold']), + "Members will be unsubscribed after being inactive for more than `{}` focus+break stages.".format( + flags['threshold'] + ) + )) + if flags['channelname']: + # Handle channel name update + to_set.append(( + 'channel_name', + flags['channelname'], + f"The voice channel name template is now `{flags['channelname']}`." + )) + + if to_set: + to_update = {item[0]: item[1] for item in to_set} + timer.data.update(**to_update) + desc = '\n'.join(f"{tick} {item[2]}" for item in to_set) + embed = discord.Embed( + title=f"Timer option{'s' if len(to_update) > 1 else ''} updated!", + description=desc, + colour=discord.Colour.green() + ) + await ctx.reply(embed=embed) + else: + # Flags were provided, but there is no timer, and no timer was created + await ctx.error_reply( + f"No timer exists in {channel.mention} to set up!\n" + f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```" + f"See `{ctx.best_prefix}help pomodoro` for more examples and usage." + ) + diff --git a/bot/modules/study/timers/data.py b/bot/modules/study/timers/data.py new file mode 100644 index 00000000..ac458849 --- /dev/null +++ b/bot/modules/study/timers/data.py @@ -0,0 +1,14 @@ +from data import RowTable + + +timers = RowTable( + 'timers', + ('channelid', 'guildid', + 'focus_length', 'break_length', + 'inactivity_threshold', + 'last_started', + 'text_channelid', + 'channel_name', 'pretty_name'), + 'channelid', + cache={} +) diff --git a/data/schema.sql b/data/schema.sql index 92f40c2f..ae009c58 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -746,4 +746,19 @@ CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_ta -- }}} +-- Timer Data {{{ +create TABLE timers( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid), + text_channelid BIGINT, + focus_length INTEGER NOT NULL, + break_length INTEGER NOT NULL, + last_started TIMESTAMPTZ NOT NULL, + inactivity_threshold INTEGER, + channel_name TEXT, + pretty_name TEXT +); +CREATE INDEX timers_guilds ON timers (guildid); +-- }}} + -- vim: set fdm=marker: From dd4fa985dfe2a2c2caf3313e789209b78d0cd3f7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 10 Jan 2022 17:02:14 +0200 Subject: [PATCH 05/21] (timer): UI improvements. Add `pomodoro_channel` guild setting. Add customisable per-timer text channel. Improve `reaction_message` flow. Change algorithm for updating vc name. Add `stage` and `pattern` vc name substitutions. --- bot/core/data.py | 3 +- bot/modules/study/timers/Timer.py | 42 ++++++++--- bot/modules/study/timers/__init__.py | 1 + bot/modules/study/timers/commands.py | 109 ++++++++++++++++++++++----- bot/modules/study/timers/data.py | 1 + bot/modules/study/timers/settings.py | 47 ++++++++++++ data/schema.sql | 3 +- 7 files changed, 173 insertions(+), 33 deletions(-) create mode 100644 bot/modules/study/timers/settings.py diff --git a/bot/core/data.py b/bot/core/data.py index 475ba5a3..9b7f0bdd 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -32,7 +32,8 @@ guild_config = RowTable( 'accountability_reward', 'accountability_price', 'video_studyban', 'video_grace_period', 'greeting_channel', 'greeting_message', 'returning_message', - 'starting_funds', 'persist_roles'), + 'starting_funds', 'persist_roles', + 'pomodoro_channel'), 'guildid', cache=TTLCache(2500, ttl=60*5) ) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index 8946db56..cbfbaa2d 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -1,3 +1,4 @@ +import math import asyncio import discord from collections import namedtuple @@ -110,12 +111,15 @@ class Timer: @property def text_channel(self): - return GuildSettings(self.data.guildid).alert_channel.value + if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)): + return channel + else: + return GuildSettings(self.data.guildid).pomodoro_channel.value @property def members(self): if (channel := self.channel): - return channel.members + return [member for member in channel.members if not member.bot] else: return [] @@ -127,17 +131,26 @@ class Timer: stage = self.current_stage name_format = self.data.channel_name or "{remaining} -- {name}" return name_format.replace( - '{remaining}', "{:02}:{:02}".format( - int((stage.end - utc_now()).total_seconds() // 60), - int((stage.end - utc_now()).total_seconds() % 60), + '{remaining}', "{}m left".format( + int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), ) + ).replace( + '{stage}', stage.name ).replace( '{members}', str(len(self.channel.members)) ).replace( '{name}', self.data.pretty_name or "WORK ROOM" + ).replace( + '{pattern}', + "{}/{}".format( + int(self.focus_length // 60), int(self.break_length // 60) + ) ) async def notify_change_stage(self, old_stage, new_stage): + # Update channel name + asyncio.create_task(self._update_channel_name()) + # Kick people if they need kicking to_warn = [] to_kick = [] @@ -174,7 +187,13 @@ class Timer: ) content.append(warn_string) + # Send a new status/reaction message if self.text_channel and self.members: + if self.reaction_message: + try: + await self.reaction_message.delete() + except discord.HTTPException: + pass # Send status image, add reaction self.reaction_message = await self.text_channel.send( content='\n'.join(content), @@ -192,6 +211,8 @@ class Timer: *(self.text_channel.send(block, delete_after=0.5) for block in blocks), return_exceptions=True ) + elif not self.members: + await self.update_last_status() # TODO: DM task if anyone has notifications on # Mute or unmute everyone in the channel as needed @@ -206,9 +227,6 @@ class Timer: # except discord.HTTPException: # pass - # Update channel name - asyncio.create_task(self._update_channel_name()) - # Run the notify hook await self.notify_hook(old_stage, new_stage) @@ -294,7 +312,7 @@ class Timer: else: repost = False - if repost: + if repost and self.text_channel: try: self.reaction_message = await self.text_channel.send(**args) await self.reaction_message.add_reaction('✅') @@ -338,7 +356,8 @@ class Timer: stage = self._state = self.current_stage to_next_stage = (stage.end - utc_now()).total_seconds() - if to_next_stage > 10 * 60: + # Allow updating with 10 seconds of drift to stage change + if to_next_stage > 10 * 60 - 10: time_to_sleep = 5 * 60 else: time_to_sleep = to_next_stage @@ -353,6 +372,7 @@ class Timer: asyncio.create_task(self.notify_change_stage(self._state, self.current_stage)) else: asyncio.create_task(self._update_channel_name()) + asyncio.create_task(self.update_last_status()) def runloop(self): self._runloop_task = asyncio.create_task(self.run()) @@ -392,7 +412,7 @@ async def load_timers(client): async def reaction_tracker(client, payload): if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice: if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)): - if payload.message_id == timer.reaction_message.id: + if timer.reaction_message and payload.message_id == timer.reaction_message.id: timer.last_seen[payload.member.id] = utc_now() diff --git a/bot/modules/study/timers/__init__.py b/bot/modules/study/timers/__init__.py index fb9b1bc2..dd146ae3 100644 --- a/bot/modules/study/timers/__init__.py +++ b/bot/modules/study/timers/__init__.py @@ -1,2 +1,3 @@ from .Timer import Timer from . import commands +from . import settings diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 022bf96c..dd0dd4c0 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -1,10 +1,9 @@ +import asyncio import discord from cmdClient import Context from cmdClient.checks import in_guild from cmdClient.lib import SafeCancellation -from datetime import timedelta - from wards import guild_admin from utils.lib import utc_now, tick @@ -13,12 +12,13 @@ from ..module import module from .Timer import Timer -config_flags = ('name==', 'threshold=', 'channelname==') +config_flags = ('name==', 'threshold=', 'channelname==', 'text==') + @module.cmd( "timer", group="Productivity", - desc="Display your study room pomodoro timer.", + desc="View your study room timer.", flags=config_flags ) @in_guild() @@ -32,7 +32,7 @@ async def cmd_timer(ctx: Context, flags): """ channel = ctx.author.voice.channel if ctx.author.voice else None if ctx.args: - if len(splits := ctx.args.split()) > 1: + if len(ctx.args.split()) > 1: # Multiple arguments provided # Assume configuration attempt return await _pomo_admin(ctx, flags) @@ -48,6 +48,7 @@ async def cmd_timer(ctx: Context, flags): if channel is None: # Author is not in a voice channel, and they did not select a channel # Display the server timers they can see + # TODO: Write UI timers = Timer.fetch_guild_timers(ctx.guild.id) timers = [ timer for timer in timers @@ -114,24 +115,38 @@ async def cmd_timer(ctx: Context, flags): @module.cmd( "pomodoro", group="Guild Admin", - desc="Create and modify the voice channel pomodoro timers.", + desc="Add and configure timers for your study rooms.", flags=config_flags ) -async def ctx_pomodoro(ctx, flags): +async def cmd_pomodoro(ctx, flags): """ Usage``: - {prefix}pomodoro [channelid] , [channel name] [options] + {prefix}pomodoro [channelid] , [channel name] {prefix}pomodoro [channelid] [options] {prefix}pomodoro [channelid] delete Description: - ... + Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`. + The timer will start automatically and continue forever. + See the options and examples below for configuration. Options:: - --name: The name of the timer as shown in the timer status. - --channelname: The voice channel name template. - --threshold: How many work+break sessions before a user is removed. - Examples``: - {prefix}pomodoro 50, 10 - ... + --name: The timer name (as shown in alerts and `{prefix}timer`). + --channelname: The name of the voice channel, see below for substitutions. + --threshold: How many focus+break cycles before a member is kicked. + --text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`). + Channel name substitutions:: + {{remaining}}: The time left in the current focus or break session, e.g. `10m left`. + {{stage}}: The name of the current stage (`FOCUS` or `BREAK`). + {{name}}: The configured timer name. + {{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`). + Examples: + Add a timer to your study room with `50` minutes focus, `10` minutes break. + > `{prefix}pomodoro 50, 10` + Add a timer with a custom updating channel name + > `{prefix}pomodoro 50, 10 {{stage}} {{remaining}} -- {{pattern}} room` + Change the name on the `{prefix}timer` status + > `{prefix}pomodoro --name 50/10 study room` + Change the updating channel name + > `{prefix}pomodoro --channelname {{remaining}} -- {{name}}` """ await _pomo_admin(ctx, flags) @@ -146,7 +161,10 @@ async def _pomo_admin(ctx, flags): args = ctx.args if ctx.args: splits = ctx.args.split(maxsplit=1) - if splits[0].strip('#<>').isdigit() or len(splits[0]) > 10: + assume_channel = not splits[0].endswith(',') + assume_channel = assume_channel and not (channel and len(splits[0]) < 5) + assume_channel = assume_channel and (splits[0].strip('#<>').isdigit() or len(splits[0]) > 10) + if assume_channel: # Assume first argument is a channel specifier channel = await ctx.find_channel( splits[0], interactive=True, chan_type=discord.ChannelType.voice @@ -167,8 +185,8 @@ async def _pomo_admin(ctx, flags): if not channel: return await ctx.error_reply( f"No channel specified!\n" - "Please join a voice channel or pass the id as the first argument.\n" - f"See `{ctx.best_prefix}help pomodoro` for more usage information." + "Please join a voice channel or pass the channel id as the first argument.\n" + f"See `{ctx.best_prefix}help pomodoro` for usage and examples." ) # Now we have a channel and configuration arguments @@ -196,6 +214,13 @@ async def _pomo_admin(ctx, flags): elif args or timer: if args: # Any provided arguments should be for setting up a new timer pattern + # Check the pomodoro channel exists + if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value: + return await ctx.error_reply( + "Please set the pomodoro alerts channel first, " + f"with `{ctx.best_prefix}config pomodoro_channel `.\n" + f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}" + ) # First validate input try: # Ensure no trailing commas @@ -294,7 +319,8 @@ async def _pomo_admin(ctx, flags): timer.runloop() await ctx.embed_reply( - f"Restarted the pomodoro timer in {channel.mention} as `{focus_length}, {break_length}`." + f"Started a timer in {channel.mention} with **{focus_length}** " + f"minutes focus and **{break_length}** minutes break." ) to_set = [] @@ -325,6 +351,50 @@ async def _pomo_admin(ctx, flags): flags['channelname'], f"The voice channel name template is now `{flags['channelname']}`." )) + if flags['text']: + # Handle text channel update + flag = flags['text'] + if flag.lower() == 'none': + # Check if there is a default channel + channel = ctx.guild_settings.pomodoro_channel.value + if channel: + # Unset the channel to the default + msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})" + to_set.append(( + 'text_channelid', + None, + msg + )) + # Remove the last reaction message and send a new one + timer.reaction_message = None + # Ensure this happens after the data update + asyncio.create_task(timer.update_last_status()) + else: + return await ctx.error_reply( + "The text channel cannot be unset because there is no `pomodoro_channel` set up!\n" + f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel." + ) + else: + # Attempt to parse the provided channel + channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text) + if channel: + if not channel.permissions_for(ctx.guild.me.send_messages): + return await ctx.error_reply( + f"Cannot send pomodoro alerts to {channel.mention}! " + "I don't have permission to send messages there." + ) + to_set.append(( + 'text_channelid', + channel.id, + f"Timer alerts and updates will now be sent to {channel.mention}." + )) + # Remove the last reaction message and send a new one + timer.reaction_message = None + # Ensure this happens after the data update + asyncio.create_task(timer.update_last_status()) + else: + # Ack has already been sent, just ignore + return if to_set: to_update = {item[0]: item[1] for item in to_set} @@ -343,4 +413,3 @@ async def _pomo_admin(ctx, flags): f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```" f"See `{ctx.best_prefix}help pomodoro` for more examples and usage." ) - diff --git a/bot/modules/study/timers/data.py b/bot/modules/study/timers/data.py index ac458849..3103610c 100644 --- a/bot/modules/study/timers/data.py +++ b/bot/modules/study/timers/data.py @@ -4,6 +4,7 @@ from data import RowTable timers = RowTable( 'timers', ('channelid', 'guildid', + 'text_channelid', 'focus_length', 'break_length', 'inactivity_threshold', 'last_started', diff --git a/bot/modules/study/timers/settings.py b/bot/modules/study/timers/settings.py new file mode 100644 index 00000000..8c3029e0 --- /dev/null +++ b/bot/modules/study/timers/settings.py @@ -0,0 +1,47 @@ +import asyncio + +from settings import GuildSettings, GuildSetting +import settings + +from . import Timer + + +@GuildSettings.attach_setting +class pomodoro_channel(settings.TextChannel, GuildSetting): + category = "Study Tracking" + + attr_name = "pomodoro_channel" + _data_column = "pomodoro_channel" + + display_name = "pomodoro_channel" + desc = "Channel to send pomodoro timer status updates and alerts." + + _default = None + + long_desc = ( + "Channel to send pomodoro status updates to.\n" + "Members studying in rooms with an attached timer will need to be able to see " + "this channel to get notifications and react to the status messages." + ) + _accepts = "Any text channel I can write to, or `None` to unset." + + @property + def success_response(self): + timers = Timer.fetch_guild_timers(self.id) + if self.value: + for timer in timers: + if timer.reaction_message and timer.reaction_message.channel != self.value: + timer.reaction_message = None + asyncio.create_task(timer.update_last_status()) + return f"The pomodoro alerts and updates will now be sent to {self.value.mention}" + else: + deleted = 0 + for timer in timers: + if not timer.text_channel: + deleted += 1 + asyncio.create_task(timer.destroy()) + + msg = "The pomodoro alert channel has been unset." + if deleted: + msg += f" `{deleted}` timers were subsequently deactivated." + return msg diff --git a/data/schema.sql b/data/schema.sql index ae009c58..928d143d 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -78,7 +78,8 @@ CREATE TABLE guild_config( returning_message TEXT, starting_funds INTEGER, persist_roles BOOLEAN, - daily_study_cap INTEGER + daily_study_cap INTEGER, + pomodoro_channel BIGINT ); CREATE TABLE ignored_members( From 14cab843a312c1c2170aca31f0efdc87c2b56a93 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 10 Jan 2022 17:23:44 +0200 Subject: [PATCH 06/21] (timer): Limit number of timers per guild. --- bot/modules/study/timers/commands.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index dd0dd4c0..a5778280 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -13,6 +13,7 @@ from .Timer import Timer config_flags = ('name==', 'threshold=', 'channelname==', 'text==') +MAX_TIMERS_PER_GUILD = 10 @module.cmd( @@ -270,6 +271,13 @@ async def _pomo_admin(ctx, flags): # Create or update the timer if not timer: # Create timer + # First check number of timers + timers = Timer.fetch_guild_timers(ctx.guild.id) + if len(timers) >= MAX_TIMERS_PER_GUILD: + return ctx.error_reply( + "Cannot create another timer!\n" + "This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD) + ) # First check permissions if not channel.permissions_for(ctx.guild.me).send_messages: embed = discord.Embed( @@ -297,7 +305,8 @@ async def _pomo_admin(ctx, flags): await timer.update_last_status() await ctx.embed_reply( - f"Started a new `{focus_length}, {break_length}` pomodoro timer in {channel.mention}." + f"Started a timer in {channel.mention} with **{focus_length}** minutes focus " + f"and **{break_length}** minutes break." ) else: # Update timer and restart From e6cbd31a232c6d3cad9db852d6c604f929e89a27 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 10 Jan 2022 18:34:38 +0200 Subject: [PATCH 07/21] (timer): Better timer listing UI. --- bot/modules/study/timers/commands.py | 48 ++++++++++++++++------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index a5778280..f25d77e8 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -49,49 +49,57 @@ async def cmd_timer(ctx: Context, flags): if channel is None: # Author is not in a voice channel, and they did not select a channel # Display the server timers they can see - # TODO: Write UI timers = Timer.fetch_guild_timers(ctx.guild.id) timers = [ timer for timer in timers if timer.channel and timer.channel.permissions_for(ctx.author).view_channel ] if not timers: - return await ctx.error_reply( - "There are no available timers!" - ) + if await guild_admin.run(ctx): + return await ctx.error_reply( + "No timers are running yet!\n" + f"Start a timer by joining a voice channel and running e.g. `{ctx.best_prefix}pomodoro 50, 10`.\n" + f"See `{ctx.best_prefix}help pomodoro for detailed usage." + ) + else: + return await ctx.error_reply( + "No timers are running!\n" + f"You can ask an admin to start one using `{ctx.best_prefix}pomodoro`." + ) # Build a summary list timer_strings = [] for timer in timers: stage = timer.current_stage - stage_str = "**{}** minutes focus with **{}** minutes break".format( - timer.focus_length // 60, timer.break_length // 60 + stage_str = "(**`{}m`** focus, **`{}m`** break)".format( + int(timer.focus_length // 60), int(timer.break_length // 60) ) + if len(timer.members) > 1: + member_str = "**{}** members are ".format(len(timer.members)) + elif len(timer.members) == 1: + member_str = "{} is ".format(timer.members[0].mention) + else: + member_str = "" remaining = (stage.end - utc_now()).total_seconds() - memberstr = ', '.join(member.mention for member in timer.members[:20]) - if len(timer.members) > 20: - memberstr += '...' - timer_strings.append( - ("{}: {}\n" - "Currently in `{}`, with `{:02}:{:02}` remaining.\n" - "{}").format( + ("{} {}\n" + "{}urrently **{}** with `{:02}:{:02}` left.").format( timer.channel.mention, stage_str, - stage.name, + member_str + 'c' if member_str else 'C', + "focusing" if stage.name == "FOCUS" else "resting", int(remaining // 3600), int((remaining // 60) % 60), - memberstr ) ) blocks = [ - '\n\n'.join(timer_strings[i:i+4]) - for i in range(0, len(timer_strings), 4) + '\n\n'.join(timer_strings[i:i+10]) + for i in range(0, len(timer_strings), 10) ] embeds = [ discord.Embed( - title="Pomodoro Timers", + title="Study Timers", description=block, colour=discord.Colour.orange() ) @@ -105,7 +113,7 @@ async def cmd_timer(ctx: Context, flags): if timer is None: # No timer in this channel return await ctx.error_reply( - f"{channel.mention} doesn't have a timer!" + f"{channel.mention} doesn't have a timer running!" ) else: # We have a timer @@ -162,7 +170,7 @@ async def _pomo_admin(ctx, flags): args = ctx.args if ctx.args: splits = ctx.args.split(maxsplit=1) - assume_channel = not splits[0].endswith(',') + assume_channel = not splits[0].contains(',') assume_channel = assume_channel and not (channel and len(splits[0]) < 5) assume_channel = assume_channel and (splits[0].strip('#<>').isdigit() or len(splits[0]) > 10) if assume_channel: From 5fbf84537d0ee40447c762e249fc7216661719e3 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 10 Jan 2022 18:55:00 +0200 Subject: [PATCH 08/21] (data): Start migration v7 -> v8. --- bot/constants.py | 2 +- data/migration/v7-v8/migration.sql | 18 ++++++++++++++++++ data/schema.sql | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 data/migration/v7-v8/migration.sql diff --git a/bot/constants.py b/bot/constants.py index 4594570a..6c122bcc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 7 +DATA_VERSION = 8 diff --git a/data/migration/v7-v8/migration.sql b/data/migration/v7-v8/migration.sql new file mode 100644 index 00000000..d93850e8 --- /dev/null +++ b/data/migration/v7-v8/migration.sql @@ -0,0 +1,18 @@ +ALTER TABLE guild_config ADD COLUMN pomodoro_channel BIGINT; + +-- Timer Data {{{ +create TABLE timers( + channelid BIGINT PRIMARY KEY, + guildid BIGINT NOT NULL REFERENCES guild_config (guildid), + text_channelid BIGINT, + focus_length INTEGER NOT NULL, + break_length INTEGER NOT NULL, + last_started TIMESTAMPTZ NOT NULL, + inactivity_threshold INTEGER, + channel_name TEXT, + pretty_name TEXT +); +CREATE INDEX timers_guilds ON timers (guildid); +-- }}} + +INSERT INTO VersionHistory (version, author) VALUES (8, 'v7-v8 migration'); diff --git a/data/schema.sql b/data/schema.sql index 928d143d..b82e8cc5 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 (7, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (8, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() From f409d2b95560f905c3537d0ec61a136144d464ea Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 10 Jan 2022 19:05:04 +0200 Subject: [PATCH 09/21] fix (timer): Fix typo. --- bot/modules/study/timers/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index f25d77e8..46ad0eb5 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -170,7 +170,7 @@ async def _pomo_admin(ctx, flags): args = ctx.args if ctx.args: splits = ctx.args.split(maxsplit=1) - assume_channel = not splits[0].contains(',') + assume_channel = not (',' in splits[0]) assume_channel = assume_channel and not (channel and len(splits[0]) < 5) assume_channel = assume_channel and (splits[0].strip('#<>').isdigit() or len(splits[0]) > 10) if assume_channel: From 4cd4fab68f0c8f48bdd666b48a8408cca54ff368 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 10:32:30 +0200 Subject: [PATCH 10/21] (timer): Various bugfixes. --- bot/modules/study/timers/Timer.py | 5 ++++- bot/modules/study/timers/commands.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index cbfbaa2d..9fd97f51 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -325,6 +325,9 @@ class Timer: """ Remove the timer. """ + # Remove timer from cache + self.timers.pop(self.channelid) + # Cancel the loop if self._run_task: self._run_task.cancel() @@ -424,5 +427,5 @@ async def touch_member(client, member, before, after): await timer.update_last_status() if before.channel and (timer := Timer.fetch_timer(before.channel.id)): - timer.last_seen.pop(member.id) + timer.last_seen.pop(member.id, None) await timer.update_last_status() diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 46ad0eb5..39cbf68f 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -172,7 +172,7 @@ async def _pomo_admin(ctx, flags): splits = ctx.args.split(maxsplit=1) assume_channel = not (',' in splits[0]) assume_channel = assume_channel and not (channel and len(splits[0]) < 5) - assume_channel = assume_channel and (splits[0].strip('#<>').isdigit() or len(splits[0]) > 10) + assume_channel = assume_channel or (splits[0].strip('#<>').isdigit() and len(splits[0]) > 10) if assume_channel: # Assume first argument is a channel specifier channel = await ctx.find_channel( @@ -269,7 +269,7 @@ async def _pomo_admin(ctx, flags): ) if e.msg: usage.description = e.msg - return ctx.reply(embed=usage) + return await ctx.reply(embed=usage) # Input validation complete, assign values focus_length = int(timesplits[0]) @@ -282,7 +282,7 @@ async def _pomo_admin(ctx, flags): # First check number of timers timers = Timer.fetch_guild_timers(ctx.guild.id) if len(timers) >= MAX_TIMERS_PER_GUILD: - return ctx.error_reply( + return await ctx.error_reply( "Cannot create another timer!\n" "This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD) ) From 271ecfffa4935bb9e908af609c83e44549d17999 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 17:51:21 +0200 Subject: [PATCH 11/21] (timer): Channel template changes. --- bot/modules/study/timers/Timer.py | 6 +++--- bot/modules/study/timers/commands.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index 9fd97f51..3476d9a2 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -129,13 +129,13 @@ class Timer: Current name for the voice channel """ stage = self.current_stage - name_format = self.data.channel_name or "{remaining} -- {name}" + name_format = self.data.channel_name or "{remaining} {stage} -- {name}" return name_format.replace( - '{remaining}', "{}m left".format( + '{remaining}', "{}m".format( int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), ) ).replace( - '{stage}', stage.name + '{stage}', stage.name.lower() ).replace( '{members}', str(len(self.channel.members)) ).replace( diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 39cbf68f..77fec0d8 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -143,19 +143,19 @@ async def cmd_pomodoro(ctx, flags): --threshold: How many focus+break cycles before a member is kicked. --text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`). Channel name substitutions:: - {{remaining}}: The time left in the current focus or break session, e.g. `10m left`. - {{stage}}: The name of the current stage (`FOCUS` or `BREAK`). + {{remaining}}: The time left in the current focus or break session, e.g. `10m`. + {{stage}}: The name of the current stage (`focus` or `break`). {{name}}: The configured timer name. {{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`). Examples: Add a timer to your study room with `50` minutes focus, `10` minutes break. > `{prefix}pomodoro 50, 10` Add a timer with a custom updating channel name - > `{prefix}pomodoro 50, 10 {{stage}} {{remaining}} -- {{pattern}} room` + > `{prefix}pomodoro 50, 10 {{remaining}} {{stage}} -- {{pattern}} room` Change the name on the `{prefix}timer` status > `{prefix}pomodoro --name 50/10 study room` Change the updating channel name - > `{prefix}pomodoro --channelname {{remaining}} -- {{name}}` + > `{prefix}pomodoro --channelname {{remaining}} left -- {{name}}` """ await _pomo_admin(ctx, flags) From 331dece8593aefb6739c6b0415dae99ea8a2d6cd Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 17:55:22 +0200 Subject: [PATCH 12/21] (timer): Lower limit on stage length. --- bot/modules/study/timers/commands.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 77fec0d8..305d3021 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -276,6 +276,12 @@ async def _pomo_admin(ctx, flags): break_length = int(breaksplits[0]) channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None + # Check the stages aren't too short + if focus_length < 5: + return await ctx.error_reply("The focus duration must be at least 5 minutes!") + if break_length < 5: + return await ctx.error_reply("The break duration must be at least 5 minutes!") + # Create or update the timer if not timer: # Create timer From 9e9ae18a4a9285d4c71a29f530f63f48725e529c Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 19:45:28 +0200 Subject: [PATCH 13/21] (timer): Update notify instruction order. Resolves a potential race condition between joining and updating. --- bot/modules/study/timers/Timer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index 3476d9a2..bef4aa72 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -5,6 +5,7 @@ from collections import namedtuple from datetime import timedelta from utils.lib import utc_now +from utils.interactive import discord_shield from meta import client from settings import GuildSettings from data.conditions import THIS_SHARD @@ -189,11 +190,8 @@ class Timer: # Send a new status/reaction message if self.text_channel and self.members: - if self.reaction_message: - try: - await self.reaction_message.delete() - except discord.HTTPException: - pass + old_reaction_message = self.reaction_message + # Send status image, add reaction self.reaction_message = await self.text_channel.send( content='\n'.join(content), @@ -201,6 +199,9 @@ class Timer: ) await self.reaction_message.add_reaction('✅') + if old_reaction_message: + asyncio.create_task(discord_shield(old_reaction_message.delete())) + # Ping people members = self.members blocks = [ From 29aff1bcb20a673c637b9ae8d797799a3f1e1fa4 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 19:54:18 +0200 Subject: [PATCH 14/21] (timer): Add `timers` alias. --- bot/modules/study/timers/commands.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 305d3021..319d4de4 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -20,18 +20,21 @@ MAX_TIMERS_PER_GUILD = 10 "timer", group="Productivity", desc="View your study room timer.", - flags=config_flags + flags=config_flags, + aliases=('timers',) ) @in_guild() async def cmd_timer(ctx: Context, flags): """ Usage``: {prefix}timer + {prefix}timers Description: Display your current study room timer status. If you aren't in a study room, instead shows a list of timers you can join. + Use `{prefix}timers` to always show the list of timers instead. """ - channel = ctx.author.voice.channel if ctx.author.voice else None + channel = ctx.author.voice.channel if ctx.author.voice and ctx.alias.lower() != 'timers' else None if ctx.args: if len(ctx.args.split()) > 1: # Multiple arguments provided From 0e5a11ee41f930fa888777a9c7f4730ca7b4a63c Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 20:02:33 +0200 Subject: [PATCH 15/21] (wards): `guild_admin` inherits from `owner` ward. --- bot/wards.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/wards.py b/bot/wards.py index 09965a46..8dce9a3b 100644 --- a/bot/wards.py +++ b/bot/wards.py @@ -1,10 +1,15 @@ from cmdClient import check from cmdClient.checks import in_guild +from meta import client + from data import tables def is_guild_admin(member): + if member.id in client.owners: + return True + # First check guild admin permissions admin = member.guild_permissions.administrator From e6e0d7a9a3c8927f495ab61071466431783baf9a Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 23:31:42 +0200 Subject: [PATCH 16/21] (sessions): Add customisable study tags. --- bot/modules/study/tracking/__init__.py | 1 + bot/modules/study/tracking/data.py | 1 + data/migration/v7-v8/migration.sql | 49 ++++++++++++++++++++++++++ data/schema.sql | 8 +++-- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/bot/modules/study/tracking/__init__.py b/bot/modules/study/tracking/__init__.py index ba8de231..c52b4662 100644 --- a/bot/modules/study/tracking/__init__.py +++ b/bot/modules/study/tracking/__init__.py @@ -1,3 +1,4 @@ from . import data from . import settings from . import session_tracker +from . import commands diff --git a/bot/modules/study/tracking/data.py b/bot/modules/study/tracking/data.py index d9dcae38..b3cb8dc7 100644 --- a/bot/modules/study/tracking/data.py +++ b/bot/modules/study/tracking/data.py @@ -20,6 +20,7 @@ session_history = Table('session_history') current_sessions = RowTable( 'current_sessions', ('guildid', 'userid', 'channelid', 'channel_type', + 'rating', 'tag', 'start_time', 'live_duration', 'live_start', 'stream_duration', 'stream_start', diff --git a/data/migration/v7-v8/migration.sql b/data/migration/v7-v8/migration.sql index d93850e8..849d9a0c 100644 --- a/data/migration/v7-v8/migration.sql +++ b/data/migration/v7-v8/migration.sql @@ -15,4 +15,53 @@ create TABLE timers( CREATE INDEX timers_guilds ON timers (guildid); -- }}} +-- Session tags {{{ +ALTER TABLE current_sessions + ADD COLUMN rating INTEGER, + ADD COLUMN tag TEXT; + +ALTER TABLE session_history + ADD COLUMN rating INTEGER, + ADD COLUMN tag TEXT; + +DROP FUNCTION close_study_session; + +CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT) + RETURNS SETOF members +AS $$ + BEGIN + RETURN QUERY + WITH + current_sesh AS ( + DELETE FROM current_sessions + WHERE guildid=_guildid AND userid=_userid + RETURNING + *, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration, + stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration, + video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration, + live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration + ), saved_sesh AS ( + INSERT INTO session_history ( + guildid, userid, channelid, rating, tag, channel_type, start_time, + duration, stream_duration, video_duration, live_duration, + coins_earned + ) SELECT + guildid, userid, channelid, rating, tag, channel_type, start_time, + total_duration, total_stream_duration, total_video_duration, total_live_duration, + (total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600 + FROM current_sesh + RETURNING * + ) + UPDATE members + SET + tracked_time=(tracked_time + saved_sesh.duration), + coins=(coins + saved_sesh.coins_earned) + FROM saved_sesh + WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid + RETURNING members.*; + END; +$$ LANGUAGE PLPGSQL; +-- }}} + INSERT INTO VersionHistory (version, author) VALUES (8, 'v7-v8 migration'); diff --git a/data/schema.sql b/data/schema.sql index b82e8cc5..627525fe 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -427,6 +427,8 @@ CREATE TABLE session_history( userid BIGINT NOT NULL, channelid BIGINT, channel_type SessionChannelType, + rating INTEGER, + tag TEXT, start_time TIMESTAMPTZ NOT NULL, duration INTEGER NOT NULL, coins_earned INTEGER NOT NULL, @@ -442,6 +444,8 @@ CREATE TABLE current_sessions( userid BIGINT NOT NULL, channelid BIGINT, channel_type SessionChannelType, + rating INTEGER, + tag TEXT, start_time TIMESTAMPTZ DEFAULT now(), live_duration INTEGER DEFAULT 0, live_start TIMESTAMPTZ, @@ -510,11 +514,11 @@ AS $$ live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration ), saved_sesh AS ( INSERT INTO session_history ( - guildid, userid, channelid, channel_type, start_time, + guildid, userid, channelid, rating, tag, channel_type, start_time, duration, stream_duration, video_duration, live_duration, coins_earned ) SELECT - guildid, userid, channelid, channel_type, start_time, + guildid, userid, channelid, rating, tag, channel_type, start_time, total_duration, total_stream_duration, total_video_duration, total_live_duration, (total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600 FROM current_sesh From aa6117c5a265db0f94380f11d6c3607ef0b3d5ea Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 23:34:06 +0200 Subject: [PATCH 17/21] (data): Fix function migration issue. --- data/migration/v7-v8/migration.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/migration/v7-v8/migration.sql b/data/migration/v7-v8/migration.sql index 849d9a0c..e01a6dd0 100644 --- a/data/migration/v7-v8/migration.sql +++ b/data/migration/v7-v8/migration.sql @@ -24,7 +24,7 @@ ALTER TABLE session_history ADD COLUMN rating INTEGER, ADD COLUMN tag TEXT; -DROP FUNCTION close_study_session; +DROP FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT); CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT) RETURNS SETOF members From 1629a34ea1dbdd1907ac5d4ac8634039a8ebbcc2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 11 Jan 2022 23:35:04 +0200 Subject: [PATCH 18/21] fix: Add forgotten commands file. --- bot/modules/study/tracking/commands.py | 138 +++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 bot/modules/study/tracking/commands.py diff --git a/bot/modules/study/tracking/commands.py b/bot/modules/study/tracking/commands.py new file mode 100644 index 00000000..0c64ffdf --- /dev/null +++ b/bot/modules/study/tracking/commands.py @@ -0,0 +1,138 @@ +from cmdClient import Context +from cmdClient.checks import in_guild + +from core import Lion + +from ..module import module + + +MAX_TAG_LENGTH = 10 + + +@module.cmd( + "now", + group="Statistics", + desc="What are you working on?", + aliases=('studying', 'workingon') +) +@in_guild() +async def cmd_now(ctx: Context): + """ + Usage``: + {prefix}now [tag] + {prefix}now @mention + Description: + Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`. + Mention someone else to view what they are working on! + Examples: + > {prefix}now Biology + > {prefix}now {ctx.author.mention} + """ + if ctx.args: + if ctx.msg.mentions: + # Assume peeking at user's current session + + # Smoll easter egg + target = ctx.msg.mentions[0] + if target == ctx.guild.me: + student_count, guild_count = ctx.client.data.current_sessions.select_one_where( + select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(guildid)) AS guild_count"), + ) + if ctx.alion.session: + if (tag := ctx.alion.session.data.tag): + tail = f"Good luck with your **{tag}**!" + else: + tail = "Good luck with your study, I believe in you!" + else: + tail = "Do you want to join? Hop in a study channel and let's get to work!" + return await ctx.embed_reply( + "Thanks for asking!\n" + f"I'm just helping out the **{student_count}** " + f"hardworking students currently studying across **{guild_count}** fun communities!\n" + f"{tail}" + ) + + lion = Lion.fetch(ctx.guild.id, target.id) + if not lion.session: + await ctx.embed_reply( + f"{target.mention} isn't studying right now!" + ) + else: + duration = lion.session.duration + if duration > 3600: + dur_str = "{}h {}m".format( + int(duration // 3600), + int((duration % 3600) // 60) + ) + else: + dur_str = "{} minutes".format(int((duration % 3600) // 60)) + + if not lion.session.data.tag: + await ctx.embed_reply( + f"{target.mention} has been studying in <#{lion.session.data.channelid}> for **{dur_str}**!" + ) + else: + await ctx.embed_reply( + f"{target.mention} has been working on **{lion.session.data.tag}**" + f" in <#{lion.session.data.channelid}> for **{dur_str}**!" + ) + else: + # Assume setting tag + tag = ctx.args + + if not (session := ctx.alion.session): + return await ctx.error_reply( + "You aren't studying right now! Join a study channel and try again!" + ) + + if len(tag) > MAX_TAG_LENGTH: + return await ctx.error_reply( + f"Please keep your tag under `{MAX_TAG_LENGTH}` characters long!" + ) + + old_tag = session.data.tag + session.data.tag = tag + if old_tag: + await ctx.embed_reply( + f"You have updated your session study tag. Good luck with **{tag}**!" + ) + else: + await ctx.embed_reply( + "You have set your session study tag!\nIt will be reset when you leave, or join another channel.\n" + f"Good luck with **{tag}**!" + ) + else: + # View current session, stats, and guide. + lines = [] + if (session := ctx.alion.session): + duration = session.duration + if duration > 3600: + dur_str = "{}h {}m".format( + int(duration // 3600), + int((duration % 3600) // 60) + ) + else: + dur_str = "{} minutes".format(int((duration % 3600) / 60)) + if not session.data.tag: + await ctx.embed_reply( + f"You have been studying in <#{session.data.channelid}> for **{dur_str}**!" + ) + lines.append( + f"Describe what you are working on with " + "`{ctx.best_prefix}now `, e.g. `{ctx.best_prefix}now Maths`!" + ) + else: + await ctx.embed_reply( + f"You have been working on **{session.data.tag}**" + f" in <#{session.data.channelid}> for **{dur_str}**!" + ) + else: + await ctx.embed_reply( + f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths!`" + ) + + # TODO: Favourite tags listing + # Get tag history ranking top 5 + # If there are any, display top 5 + # Otherwise do nothing + ... From d1f27ca826cb941cd3e5bfa0a7ea8a5c2d585458 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 12 Jan 2022 09:02:26 +0200 Subject: [PATCH 19/21] (cmd_now): Wording updates and `clear` pathway. --- bot/modules/study/tracking/commands.py | 55 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/bot/modules/study/tracking/commands.py b/bot/modules/study/tracking/commands.py index 0c64ffdf..c334e8b0 100644 --- a/bot/modules/study/tracking/commands.py +++ b/bot/modules/study/tracking/commands.py @@ -2,6 +2,7 @@ from cmdClient import Context from cmdClient.checks import in_guild from core import Lion +from wards import is_guild_admin from ..module import module @@ -13,22 +14,53 @@ MAX_TAG_LENGTH = 10 "now", group="Statistics", desc="What are you working on?", - aliases=('studying', 'workingon') + aliases=('studying', 'workingon'), + flags=('clear', 'new') ) @in_guild() -async def cmd_now(ctx: Context): +async def cmd_now(ctx: Context, flags): """ Usage``: {prefix}now [tag] {prefix}now @mention + {prefix}now --clear Description: Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`. Mention someone else to view what they are working on! + Flags:: + clear: Remove your current tag. Examples: > {prefix}now Biology > {prefix}now {ctx.author.mention} """ - if ctx.args: + if flags['clear']: + if ctx.msg.mentions and is_guild_admin(ctx.author): + # Assume an admin is trying to clear another user's tag + for target in ctx.msg.mentions: + lion = Lion.fetch(ctx.guild.id, target.id) + if lion.session: + lion.session.data.tag = None + + if len(ctx.msg.mentions) == 1: + await ctx.embed_reply( + f"Cleared session tags for {ctx.msg.mentions[0].mention}." + ) + else: + await ctx.embed_reply( + f"Cleared session tags for:\n{', '.join(target.mention for target in ctx.msg.mentions)}." + ) + else: + # Assume the user is clearing their own session tag + if (session := ctx.alion.session): + session.data.tag = None + await ctx.embed_reply( + "Removed your session study tag!" + ) + else: + await ctx.embed_reply( + "You aren't studying right now, so there is nothing to clear!" + ) + elif ctx.args: if ctx.msg.mentions: # Assume peeking at user's current session @@ -48,14 +80,14 @@ async def cmd_now(ctx: Context): return await ctx.embed_reply( "Thanks for asking!\n" f"I'm just helping out the **{student_count}** " - f"hardworking students currently studying across **{guild_count}** fun communities!\n" + f"dedicated people currently working across **{guild_count}** fun communities!\n" f"{tail}" ) lion = Lion.fetch(ctx.guild.id, target.id) if not lion.session: await ctx.embed_reply( - f"{target.mention} isn't studying right now!" + f"{target.mention} isn't working right now!" ) else: duration = lion.session.duration @@ -69,7 +101,7 @@ async def cmd_now(ctx: Context): if not lion.session.data.tag: await ctx.embed_reply( - f"{target.mention} has been studying in <#{lion.session.data.channelid}> for **{dur_str}**!" + f"{target.mention} has been working in <#{lion.session.data.channelid}> for **{dur_str}**!" ) else: await ctx.embed_reply( @@ -82,7 +114,7 @@ async def cmd_now(ctx: Context): if not (session := ctx.alion.session): return await ctx.error_reply( - "You aren't studying right now! Join a study channel and try again!" + "You aren't working right now! Join a study channel and try again!" ) if len(tag) > MAX_TAG_LENGTH: @@ -103,7 +135,6 @@ async def cmd_now(ctx: Context): ) else: # View current session, stats, and guide. - lines = [] if (session := ctx.alion.session): duration = session.duration if duration > 3600: @@ -115,11 +146,9 @@ async def cmd_now(ctx: Context): dur_str = "{} minutes".format(int((duration % 3600) / 60)) if not session.data.tag: await ctx.embed_reply( - f"You have been studying in <#{session.data.channelid}> for **{dur_str}**!" - ) - lines.append( + f"You have been working in <#{session.data.channelid}> for **{dur_str}**!\n" f"Describe what you are working on with " - "`{ctx.best_prefix}now `, e.g. `{ctx.best_prefix}now Maths`!" + f"`{ctx.best_prefix}now `, e.g. `{ctx.best_prefix}now Maths`" ) else: await ctx.embed_reply( @@ -128,7 +157,7 @@ async def cmd_now(ctx: Context): ) else: await ctx.embed_reply( - f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths!`" + f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths`" ) # TODO: Favourite tags listing From 6b7c708fa26c7711dfbe85a4369d150ecf5bcde1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 12 Jan 2022 09:40:34 +0200 Subject: [PATCH 20/21] fix (timer): Fix typo in `--text` setting. --- bot/modules/study/timers/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 319d4de4..7cfefc14 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -404,7 +404,7 @@ async def _pomo_admin(ctx, flags): # Attempt to parse the provided channel channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text) if channel: - if not channel.permissions_for(ctx.guild.me.send_messages): + if not channel.permissions_for(ctx.guild.me).send_messages: return await ctx.error_reply( f"Cannot send pomodoro alerts to {channel.mention}! " "I don't have permission to send messages there." From b90cfbc61507c17e737bb2acb2ed486e5d89fe8f Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 12 Jan 2022 10:15:13 +0200 Subject: [PATCH 21/21] (help): Add `Pomodoro` help group. --- bot/modules/meta/help.py | 9 +++++---- bot/modules/study/timers/commands.py | 4 ++-- bot/modules/study/tracking/commands.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index d49b12ed..65209233 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -10,6 +10,7 @@ from .module import module # Set the command groups to appear in the help group_hints = { + '🆕 Pomodoro': "*Stay in sync with your friends using our timers!*", 'Productivity': "*Use these to help you stay focused and productive!*", 'Statistics': "*StudyLion leaderboards and study statistics.*", 'Economy': "*Buy, sell, and trade with your hard-earned coins!*", @@ -20,22 +21,22 @@ group_hints = { } standard_group_order = ( - ('Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), + ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta') ) mod_group_order = ( ('Moderation', 'Meta'), - ('Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) admin_group_order = ( ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) bot_admin_group_order = ( ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) # Help embed format diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index 7cfefc14..c8d4c6d9 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -18,7 +18,7 @@ MAX_TIMERS_PER_GUILD = 10 @module.cmd( "timer", - group="Productivity", + group="🆕 Pomodoro", desc="View your study room timer.", flags=config_flags, aliases=('timers',) @@ -126,7 +126,7 @@ async def cmd_timer(ctx: Context, flags): @module.cmd( "pomodoro", - group="Guild Admin", + group="🆕 Pomodoro", desc="Add and configure timers for your study rooms.", flags=config_flags ) diff --git a/bot/modules/study/tracking/commands.py b/bot/modules/study/tracking/commands.py index c334e8b0..e8a7e778 100644 --- a/bot/modules/study/tracking/commands.py +++ b/bot/modules/study/tracking/commands.py @@ -12,7 +12,7 @@ MAX_TAG_LENGTH = 10 @module.cmd( "now", - group="Statistics", + group="🆕 Pomodoro", desc="What are you working on?", aliases=('studying', 'workingon'), flags=('clear', 'new')