(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.
This commit is contained in:
2022-01-10 17:02:14 +02:00
parent 5431877569
commit dd4fa985df
7 changed files with 173 additions and 33 deletions

View File

@@ -32,7 +32,8 @@ guild_config = RowTable(
'accountability_reward', 'accountability_price', 'accountability_reward', 'accountability_price',
'video_studyban', 'video_grace_period', 'video_studyban', 'video_grace_period',
'greeting_channel', 'greeting_message', 'returning_message', 'greeting_channel', 'greeting_message', 'returning_message',
'starting_funds', 'persist_roles'), 'starting_funds', 'persist_roles',
'pomodoro_channel'),
'guildid', 'guildid',
cache=TTLCache(2500, ttl=60*5) cache=TTLCache(2500, ttl=60*5)
) )

View File

@@ -1,3 +1,4 @@
import math
import asyncio import asyncio
import discord import discord
from collections import namedtuple from collections import namedtuple
@@ -110,12 +111,15 @@ class Timer:
@property @property
def text_channel(self): 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 @property
def members(self): def members(self):
if (channel := self.channel): if (channel := self.channel):
return channel.members return [member for member in channel.members if not member.bot]
else: else:
return [] return []
@@ -127,17 +131,26 @@ class Timer:
stage = self.current_stage stage = self.current_stage
name_format = self.data.channel_name or "{remaining} -- {name}" name_format = self.data.channel_name or "{remaining} -- {name}"
return name_format.replace( return name_format.replace(
'{remaining}', "{:02}:{:02}".format( '{remaining}', "{}m left".format(
int((stage.end - utc_now()).total_seconds() // 60), int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)),
int((stage.end - utc_now()).total_seconds() % 60),
) )
).replace(
'{stage}', stage.name
).replace( ).replace(
'{members}', str(len(self.channel.members)) '{members}', str(len(self.channel.members))
).replace( ).replace(
'{name}', self.data.pretty_name or "WORK ROOM" '{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): 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 # Kick people if they need kicking
to_warn = [] to_warn = []
to_kick = [] to_kick = []
@@ -174,7 +187,13 @@ class Timer:
) )
content.append(warn_string) content.append(warn_string)
# Send a new status/reaction message
if self.text_channel and self.members: 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 # Send status image, add reaction
self.reaction_message = await self.text_channel.send( self.reaction_message = await self.text_channel.send(
content='\n'.join(content), content='\n'.join(content),
@@ -192,6 +211,8 @@ class Timer:
*(self.text_channel.send(block, delete_after=0.5) for block in blocks), *(self.text_channel.send(block, delete_after=0.5) for block in blocks),
return_exceptions=True return_exceptions=True
) )
elif not self.members:
await self.update_last_status()
# TODO: DM task if anyone has notifications on # TODO: DM task if anyone has notifications on
# Mute or unmute everyone in the channel as needed # Mute or unmute everyone in the channel as needed
@@ -206,9 +227,6 @@ class Timer:
# except discord.HTTPException: # except discord.HTTPException:
# pass # pass
# Update channel name
asyncio.create_task(self._update_channel_name())
# Run the notify hook # Run the notify hook
await self.notify_hook(old_stage, new_stage) await self.notify_hook(old_stage, new_stage)
@@ -294,7 +312,7 @@ class Timer:
else: else:
repost = False repost = False
if repost: if repost and self.text_channel:
try: try:
self.reaction_message = await self.text_channel.send(**args) self.reaction_message = await self.text_channel.send(**args)
await self.reaction_message.add_reaction('') await self.reaction_message.add_reaction('')
@@ -338,7 +356,8 @@ class Timer:
stage = self._state = self.current_stage stage = self._state = self.current_stage
to_next_stage = (stage.end - utc_now()).total_seconds() 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 time_to_sleep = 5 * 60
else: else:
time_to_sleep = to_next_stage time_to_sleep = to_next_stage
@@ -353,6 +372,7 @@ class Timer:
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage)) asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
else: else:
asyncio.create_task(self._update_channel_name()) asyncio.create_task(self._update_channel_name())
asyncio.create_task(self.update_last_status())
def runloop(self): def runloop(self):
self._runloop_task = asyncio.create_task(self.run()) self._runloop_task = asyncio.create_task(self.run())
@@ -392,7 +412,7 @@ async def load_timers(client):
async def reaction_tracker(client, payload): async def reaction_tracker(client, payload):
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice: 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 (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() timer.last_seen[payload.member.id] = utc_now()

View File

@@ -1,2 +1,3 @@
from .Timer import Timer from .Timer import Timer
from . import commands from . import commands
from . import settings

View File

@@ -1,10 +1,9 @@
import asyncio
import discord import discord
from cmdClient import Context from cmdClient import Context
from cmdClient.checks import in_guild from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from datetime import timedelta
from wards import guild_admin from wards import guild_admin
from utils.lib import utc_now, tick from utils.lib import utc_now, tick
@@ -13,12 +12,13 @@ from ..module import module
from .Timer import Timer from .Timer import Timer
config_flags = ('name==', 'threshold=', 'channelname==') config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
@module.cmd( @module.cmd(
"timer", "timer",
group="Productivity", group="Productivity",
desc="Display your study room pomodoro timer.", desc="View your study room timer.",
flags=config_flags flags=config_flags
) )
@in_guild() @in_guild()
@@ -32,7 +32,7 @@ async def cmd_timer(ctx: Context, flags):
""" """
channel = ctx.author.voice.channel if ctx.author.voice else None channel = ctx.author.voice.channel if ctx.author.voice else None
if ctx.args: if ctx.args:
if len(splits := ctx.args.split()) > 1: if len(ctx.args.split()) > 1:
# Multiple arguments provided # Multiple arguments provided
# Assume configuration attempt # Assume configuration attempt
return await _pomo_admin(ctx, flags) return await _pomo_admin(ctx, flags)
@@ -48,6 +48,7 @@ async def cmd_timer(ctx: Context, flags):
if channel is None: if channel is None:
# Author is not in a voice channel, and they did not select a channel # Author is not in a voice channel, and they did not select a channel
# Display the server timers they can see # Display the server timers they can see
# TODO: Write UI
timers = Timer.fetch_guild_timers(ctx.guild.id) timers = Timer.fetch_guild_timers(ctx.guild.id)
timers = [ timers = [
timer for timer in timers timer for timer in timers
@@ -114,24 +115,38 @@ async def cmd_timer(ctx: Context, flags):
@module.cmd( @module.cmd(
"pomodoro", "pomodoro",
group="Guild Admin", group="Guild Admin",
desc="Create and modify the voice channel pomodoro timers.", desc="Add and configure timers for your study rooms.",
flags=config_flags flags=config_flags
) )
async def ctx_pomodoro(ctx, flags): async def cmd_pomodoro(ctx, flags):
""" """
Usage``: Usage``:
{prefix}pomodoro [channelid] <work time>, <break time> [channel name] [options] {prefix}pomodoro [channelid] <focus time>, <break time> [channel name]
{prefix}pomodoro [channelid] [options] {prefix}pomodoro [channelid] [options]
{prefix}pomodoro [channelid] delete {prefix}pomodoro [channelid] delete
Description: 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:: Options::
--name: The name of the timer as shown in the timer status. --name: The timer name (as shown in alerts and `{prefix}timer`).
--channelname: The voice channel name template. --channelname: The name of the voice channel, see below for substitutions.
--threshold: How many work+break sessions before a user is removed. --threshold: How many focus+break cycles before a member is kicked.
Examples``: --text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`).
{prefix}pomodoro 50, 10 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) await _pomo_admin(ctx, flags)
@@ -146,7 +161,10 @@ async def _pomo_admin(ctx, flags):
args = ctx.args args = ctx.args
if ctx.args: if ctx.args:
splits = ctx.args.split(maxsplit=1) 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 # Assume first argument is a channel specifier
channel = await ctx.find_channel( channel = await ctx.find_channel(
splits[0], interactive=True, chan_type=discord.ChannelType.voice splits[0], interactive=True, chan_type=discord.ChannelType.voice
@@ -167,8 +185,8 @@ async def _pomo_admin(ctx, flags):
if not channel: if not channel:
return await ctx.error_reply( return await ctx.error_reply(
f"No channel specified!\n" f"No channel specified!\n"
"Please join a voice channel or pass the id as the first argument.\n" "Please join a voice channel or pass the channel id as the first argument.\n"
f"See `{ctx.best_prefix}help pomodoro` for more usage information." f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
) )
# Now we have a channel and configuration arguments # Now we have a channel and configuration arguments
@@ -196,6 +214,13 @@ async def _pomo_admin(ctx, flags):
elif args or timer: elif args or timer:
if args: if args:
# Any provided arguments should be for setting up a new timer pattern # 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 <channel>`.\n"
f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}"
)
# First validate input # First validate input
try: try:
# Ensure no trailing commas # Ensure no trailing commas
@@ -294,7 +319,8 @@ async def _pomo_admin(ctx, flags):
timer.runloop() timer.runloop()
await ctx.embed_reply( 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 = [] to_set = []
@@ -325,6 +351,50 @@ async def _pomo_admin(ctx, flags):
flags['channelname'], flags['channelname'],
f"The voice channel name template is now `{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: if to_set:
to_update = {item[0]: item[1] for item in 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"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." f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
) )

View File

@@ -4,6 +4,7 @@ from data import RowTable
timers = RowTable( timers = RowTable(
'timers', 'timers',
('channelid', 'guildid', ('channelid', 'guildid',
'text_channelid',
'focus_length', 'break_length', 'focus_length', 'break_length',
'inactivity_threshold', 'inactivity_threshold',
'last_started', 'last_started',

View File

@@ -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

View File

@@ -78,7 +78,8 @@ CREATE TABLE guild_config(
returning_message TEXT, returning_message TEXT,
starting_funds INTEGER, starting_funds INTEGER,
persist_roles BOOLEAN, persist_roles BOOLEAN,
daily_study_cap INTEGER daily_study_cap INTEGER,
pomodoro_channel BIGINT
); );
CREATE TABLE ignored_members( CREATE TABLE ignored_members(