feature (timer): Core pomodoro system.
This commit is contained in:
408
bot/modules/study/timers/Timer.py
Normal file
408
bot/modules/study/timers/Timer.py
Normal file
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
from .Timer import Timer
|
||||
from . import commands
|
||||
|
||||
346
bot/modules/study/timers/commands.py
Normal file
346
bot/modules/study/timers/commands.py
Normal file
@@ -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] <work time>, <break time> [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] <work time>, <break time> [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."
|
||||
)
|
||||
|
||||
14
bot/modules/study/timers/data.py
Normal file
14
bot/modules/study/timers/data.py
Normal file
@@ -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={}
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user