diff --git a/bot/constants.py b/bot/constants.py index e5eb789a..6c122bcc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 6 +DATA_VERSION = 8 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/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 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/Timer.py b/bot/modules/study/timers/Timer.py new file mode 100644 index 00000000..bef4aa72 --- /dev/null +++ b/bot/modules/study/timers/Timer.py @@ -0,0 +1,432 @@ +import math +import asyncio +import discord +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 + + +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): + 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 [member for member in channel.members if not member.bot] + 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} {stage} -- {name}" + return name_format.replace( + '{remaining}', "{}m".format( + int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), + ) + ).replace( + '{stage}', stage.name.lower() + ).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 = [] + 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) + + # Send a new status/reaction message + if self.text_channel and self.members: + old_reaction_message = self.reaction_message + + # 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('✅') + + if old_reaction_message: + asyncio.create_task(discord_shield(old_reaction_message.delete())) + + # 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 + ) + 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 + # 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 + + # 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 and self.text_channel: + 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. + """ + # Remove timer from cache + self.timers.pop(self.channelid) + + # 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() + + # 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 + + 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()) + asyncio.create_task(self.update_last_status()) + + 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 timer.reaction_message and 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, None) + await timer.update_last_status() diff --git a/bot/modules/study/timers/__init__.py b/bot/modules/study/timers/__init__.py index e69de29b..dd146ae3 100644 --- a/bot/modules/study/timers/__init__.py +++ b/bot/modules/study/timers/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..c8d4c6d9 --- /dev/null +++ b/bot/modules/study/timers/commands.py @@ -0,0 +1,441 @@ +import asyncio +import discord +from cmdClient import Context +from cmdClient.checks import in_guild +from cmdClient.lib import SafeCancellation + +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==', 'text==') +MAX_TIMERS_PER_GUILD = 10 + + +@module.cmd( + "timer", + group="🆕 Pomodoro", + desc="View your study room timer.", + 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 and ctx.alias.lower() != 'timers' else None + if ctx.args: + if len(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: + 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 = "(**`{}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() + + timer_strings.append( + ("{} {}\n" + "{}urrently **{}** with `{:02}:{:02}` left.").format( + timer.channel.mention, + stage_str, + member_str + 'c' if member_str else 'C', + "focusing" if stage.name == "FOCUS" else "resting", + int(remaining // 3600), + int((remaining // 60) % 60), + ) + ) + + blocks = [ + '\n\n'.join(timer_strings[i:i+10]) + for i in range(0, len(timer_strings), 10) + ] + embeds = [ + discord.Embed( + title="Study 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 running!" + ) + else: + # We have a timer + # Show the timer status + await ctx.reply(**await timer.status()) + + +@module.cmd( + "pomodoro", + group="🆕 Pomodoro", + desc="Add and configure timers for your study rooms.", + flags=config_flags +) +async def cmd_pomodoro(ctx, flags): + """ + Usage``: + {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 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`. + {{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 {{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}} left -- {{name}}` + """ + 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) + assume_channel = not (',' in splits[0]) + assume_channel = assume_channel and not (channel and len(splits[0]) < 5) + 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( + 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 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 + # 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 + # 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 + 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 await 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 + + # 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 + # First check number of timers + timers = Timer.fetch_guild_timers(ctx.guild.id) + if len(timers) >= MAX_TIMERS_PER_GUILD: + return await 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( + 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 timer in {channel.mention} with **{focus_length}** minutes focus " + f"and **{break_length}** minutes break." + ) + 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"Started a timer in {channel.mention} with **{focus_length}** " + f"minutes focus and **{break_length}** minutes break." + ) + + 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 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} + 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..3103610c --- /dev/null +++ b/bot/modules/study/timers/data.py @@ -0,0 +1,15 @@ +from data import RowTable + + +timers = RowTable( + 'timers', + ('channelid', 'guildid', + 'text_channelid', + 'focus_length', 'break_length', + 'inactivity_threshold', + 'last_started', + 'text_channelid', + 'channel_name', 'pretty_name'), + 'channelid', + cache={} +) 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/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/commands.py b/bot/modules/study/tracking/commands.py new file mode 100644 index 00000000..e8a7e778 --- /dev/null +++ b/bot/modules/study/tracking/commands.py @@ -0,0 +1,167 @@ +from cmdClient import Context +from cmdClient.checks import in_guild + +from core import Lion +from wards import is_guild_admin + +from ..module import module + + +MAX_TAG_LENGTH = 10 + + +@module.cmd( + "now", + group="🆕 Pomodoro", + desc="What are you working on?", + aliases=('studying', 'workingon'), + flags=('clear', 'new') +) +@in_guild() +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 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 + + # 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"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 working 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 working 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 working 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. + 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 working in <#{session.data.channelid}> for **{dur_str}**!\n" + f"Describe what you are working on with " + f"`{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 + ... 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/bot/modules/study/tracking/session_tracker.py b/bot/modules/study/tracking/session_tracker.py index 8158f96a..df695a36 100644 --- a/bot/modules/study/tracking/session_tracker.py +++ b/bot/modules/study/tracking/session_tracker.py @@ -249,6 +249,9 @@ async def session_voice_tracker(client, member, before, after): """ Voice update event dispatcher for study session tracking. """ + if member.bot: + return + guild = member.guild Lion.fetch(guild.id, member.id) session = Session.get(guild.id, member.id) @@ -348,7 +351,7 @@ async def join_guild_sessions(client, guild): member for channel in guild.voice_channels for member in channel.members - if channel.members and channel.id not in untracked + if channel.members and channel.id not in untracked and not member.bot ] for member in members: client.log( @@ -460,7 +463,7 @@ async def _init_session_tracker(client): member for channel in tracked_channels for member in channel.members - if not Session.get(member.guild.id, member.id) + if not member.bot and not Session.get(member.guild.id, member.id) ] for member in new_members: client.log( 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 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 diff --git a/data/migration/v7-v8/migration.sql b/data/migration/v7-v8/migration.sql new file mode 100644 index 00000000..e01a6dd0 --- /dev/null +++ b/data/migration/v7-v8/migration.sql @@ -0,0 +1,67 @@ +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); +-- }}} + +-- 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(_guildid BIGINT, _userid BIGINT); + +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 92f40c2f..627525fe 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() @@ -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( @@ -426,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, @@ -441,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, @@ -509,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 @@ -746,4 +751,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: