From 543187756975574703755f1818e2b92807458d0a Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 8 Jan 2022 02:58:16 +0200 Subject: [PATCH] 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: