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(