rewrite: New Pomodoro Timer system.

This commit is contained in:
2023-05-19 09:45:06 +03:00
parent 8d5840c696
commit 4aa2587c45
29 changed files with 2860 additions and 12 deletions

View File

@@ -0,0 +1,800 @@
from typing import Optional, TYPE_CHECKING
import math
from collections import namedtuple
import asyncio
from datetime import timedelta, datetime
import discord
from meta import LionBot
from meta.logger import log_wrap, log_context
from utils.lib import MessageArgs, utc_now, replace_multiple
from core.lion_guild import LionGuild
from core.data import CoreData
from babel.translator import ctx_locale
from . import babel, logger
from .data import TimerData
from .ui import TimerStatusUI
from .graphics import get_timer_card
from .lib import TimerRole, channel_name_keys, focus_alert_path, break_alert_path
from .options import TimerConfig, TimerOptions
if TYPE_CHECKING:
from babel.cog import LocaleSettings
_p, _np = babel._p, babel._np
Stage = namedtuple('Stage', ['focused', 'start', 'duration', 'end'])
class Timer:
__slots__ = (
'bot',
'data',
'lguild',
'locale',
'config',
'last_seen',
'status_view',
'last_status_message',
'_hook',
'_state',
'_lock',
'_last_voice_update',
'_voice_update_task',
'_voice_update_lock',
'_run_task',
'_loop_task',
)
break_name = _p('timer|stage:break|name', "BREAK")
focus_name = _p('timer|stage:focus|name', "FOCUS")
def __init__(self, bot: LionBot, data: TimerData.Timer, lguild: LionGuild):
self.bot = bot
self.data = data
self.lguild = lguild
self.locale: LocaleSettings.GuildLocale = lguild.config.get('guild_locale')
self.config = TimerConfig(data.channelid, data)
log_context.set(f"tid: {self.data.channelid}")
# State
self.last_seen: dict[int, int] = {} # memberid -> last seen timestamp
self.status_view: Optional[TimerStatusUI] = None # Current TimerStatusUI
self.last_status_message: Optional[discord.Message] = None # Last deliever notification message
self._hook: Optional[CoreData.LionHook] = None # Cached notification webhook
self._state: Optional[Stage] = None # The currently active Stage
self._lock = asyncio.Lock() # Stage change and CRUD lock
# Timestamp of the last voice update, used to compute the next update time
self._last_voice_update = None
# Wait task for the current pending channel name update
self._voice_update_task = None
# Lock to prevent channel name update race
self._voice_update_lock = asyncio.Lock()
# Wait task for the update loop. May be safely cancelled to pause updates.
self._run_task = None
# Main loop task. Should not be cancelled.
self._loop_task = None
def __repr__(self):
return (
"<Timer "
f"channelid={self.data.channelid} "
f"channel='{self.channel}' "
f"guildid={self.data.guildid} "
f"guild='{self.guild}' "
f"members={len(self.members)} "
f"pattern='{self.data.focus_length}/{self.data.break_length}' "
f"base_name={self.data.pretty_name!r} "
f"format_string={self.data.channel_name!r}"
">"
)
# Consider exposing configurable settings through a Settings interface, for ease of formatting.
@property
def auto_restart(self) -> bool:
"""
Whether to automatically restart a stopped timer when a user joins.
"""
return bool(self.data.auto_restart)
@property
def guild(self) -> Optional[discord.Guild]:
"""
The discord.Guild that this timer belongs to.
"""
return self.bot.get_guild(self.data.guildid)
@property
def channel(self) -> Optional[discord.VoiceChannel]:
"""
The discord VoiceChannel that this timer lives in.
"""
return self.bot.get_channel(self.data.channelid)
@property
def notification_channel(self) -> Optional[discord.abc.Messageable]:
"""
The Messageable channel to which to send timer notifications.
"""
if cid := self.data.notification_channelid:
channel = self.bot.get_channel(cid)
else:
channel = self.lguild.config.get('pomodoro_channel').value
if channel is None:
channel = self.channel
return channel
async def get_notification_webhook(self) -> Optional[discord.Webhook]:
channel = self.notification_channel
if channel:
cid = channel.id
if self._hook and self._hook.channelid == cid:
hook = self._hook
else:
hook = self._hook = await self.bot.core.data.LionHook.fetch(cid)
if not hook:
# Attempt to create and save webhook
try:
if channel.permissions_for(channel.guild.me).manage_webhooks:
avatar = self.bot.user.avatar
avatar_data = (await avatar.to_file()).fp.read() if avatar else None
webhook = await channel.create_webhook(
avatar=avatar_data,
name=f"{self.bot.user.name} Pomodoro",
reason="Pomodoro Notifications"
)
hook = await self.bot.core.data.LionHook.create(
channelid=channel.id,
token=webhook.token,
webhookid=webhook.id
)
elif channel.permissions_for(channel.guild.me).send_messages:
await channel.send(
"I require the `manage_webhooks` permission to send pomodoro notifications here!"
)
except discord.HTTPException:
logger.warning(
"Unexpected Exception caught while creating timer notification webhook "
f"for timer: {self!r}",
exc_info=True
)
if hook:
return hook.as_webhook(client=self.bot)
@property
def members(self) -> list[discord.Member]:
"""
The list of members of the current timer.
Uses voice channel member cache as source-of-truth.
"""
if (chan := self.channel):
members = [member for member in chan.members if not member.bot]
else:
members = []
return members
@property
def owned(self) -> bool:
"""
Whether this timer is "owned".
Owned timers have slightly different UI.
"""
return bool(self.data.ownerid)
@property
def running(self) -> bool:
"""
Whether this timer is currently running.
"""
return bool(self.data.last_started)
@property
def channel_name(self) -> str:
"""
The configured formatted name of the voice channel.
Usually does not match the actual voice channel name due to Discord ratelimits.
"""
channel_name_format = self.data.channel_name or "{name} - {stage}"
channel_name = replace_multiple(channel_name_format, self.channel_name_map())
# Truncate to maximum name length
return channel_name[:100]
@property
def base_name(self) -> str:
if not (name := self.data.pretty_name):
pattern = f"{int(self.data.focus_length // 60)}/{int(self.data.break_length // 60)}"
name = self.bot.translator.t(_p(
'timer|default_base_name',
"Timer {pattern}"
), locale=self.locale.value).format(pattern=pattern)
return name
@property
def pattern(self) -> str:
data = self.data
return f"{int(data.focus_length // 60)}/{int(data.break_length // 60)}"
def channel_name_map(self):
"""
Compute the replace map used to format the channel name.
"""
t = self.bot.translator.t
stage = self.current_stage
pattern = self.pattern
name = self.base_name
if stage is not None:
remaining = str(int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 60)))
stagestr = t(self.focus_name if stage.focused else self.break_name, locale=self.locale.value)
else:
remaining = str(self.data.focus_length // 60)
stagestr = t(self.focus_name, locale=self.locale.value)
mapping = {
'{remaining}': remaining,
'{stage}': stagestr,
'{members}': str(len(self.members)),
'{name}': name,
'{pattern}': pattern
}
return mapping
@property
def voice_alerts(self) -> bool:
"""
Whether voice alerts are enabled for this timer.
Takes into account the default.
"""
if (alerts := self.data.voice_alerts) is None:
alerts = True
return alerts
@property
def current_stage(self) -> Optional[Stage]:
"""
Calculate the current stage.
Returns None if the timer is currently stopped.
"""
if self.running:
now = utc_now()
focusl = self.data.focus_length
breakl = self.data.break_length
interval = focusl + breakl
diff = (now - self.data.last_started).total_seconds()
diff %= interval
if diff > focusl:
stage_focus = False
stage_start = now - timedelta(seconds=(diff - focusl))
stage_duration = breakl
else:
stage_focus = True
stage_start = now - timedelta(seconds=diff)
stage_duration = focusl
stage_end = stage_start + timedelta(seconds=stage_duration)
stage = Stage(stage_focus, stage_start, stage_duration, stage_end)
else:
stage = None
return stage
@property
def inactivity_threshold(self):
if (threshold := self.data.inactivity_threshold) is None:
threshold = 3
return threshold
def get_member_role(self, member: discord.Member) -> TimerRole:
"""
Calculate the highest timer permission level of the given member.
"""
if member.guild_permissions.administrator:
role = TimerRole.ADMIN
elif member.id == self.data.ownerid:
role = TimerRole.OWNER
elif (roleid := self.data.manager_roleid) and roleid in (r.id for r in member.roles):
role = TimerRole.MANAGER
else:
role = TimerRole.OTHER
return role
@log_wrap(action="Start Timer")
async def start(self):
"""
Start a new or stopped timer.
May also be used to restart the timer.
"""
try:
async with self._lock:
now = utc_now()
self.last_seen = {
member.id: now for member in self.members
}
await self.data.update(last_started=now)
await self.send_status(with_warnings=False)
self.launch()
except Exception:
logger.exception(
f"Exception occurred while starting timer! Timer {self!r}"
)
else:
logger.info(
f"Starting timer {self!r}"
)
def warning_threshold(self, state: Stage) -> Optional[datetime]:
"""
Timestamp warning threshold for last_seen, from the given stage.
Members who have not been since this time are "at risk",
and will be kicked on the next stage change.
"""
if self.inactivity_threshold > 0:
diff = self.inactivity_threshold * (self.data.break_length + self.data.focus_length)
threshold = utc_now() - timedelta(seconds=diff)
else:
threshold = None
return threshold
@log_wrap(action='Timer Change Stage')
async def notify_change_stage(self, from_stage, to_stage, kick=True):
"""
Notify timer members that the stage has changed.
This includes deleting the last status message,
sending a new status message, pinging members, running the voice alert,
and kicking inactive members if `kick` is True.
"""
if not self.members:
t = self.bot.translator.t
await self.stop(auto_restart=True)
return
async with self._lock:
tasks = []
after_tasks = []
# Submit channel name update request
after_tasks.append(asyncio.create_task(self._update_channel_name()))
if kick and (threshold := self.warning_threshold(from_stage)):
now = utc_now()
# Kick people who need kicking
needs_kick = []
for member in self.members:
last_seen = self.last_seen.get(member.id, None)
if last_seen is None:
last_seen = self.last_seen[member.id] = now
elif last_seen < threshold:
needs_kick.append(member)
for member in needs_kick:
tasks.append(member.edit(voice_channel=None))
notify_hook = await self.get_notification_webhook()
if needs_kick:
t = self.bot.translator.t
kick_message = t(_np(
'timer|kicked_message',
"{mentions} was removed from {channel} because they were inactive! "
"Remember to press {tick} to register your presence every stage.",
"{mentions} were removed from {channel} because they were inactive! "
"Remember to press {tick} to register your presence every stage.",
len(needs_kick)
), locale=self.locale.value).format(
channel=self.channel.mention,
mentions=', '.join(member.mention for member in needs_kick),
tick=self.bot.config.emojis.tick
)
tasks.append(notify_hook.send(kick_message))
if self.voice_alerts:
after_tasks.append(asyncio.create_task(self._voice_alert(to_stage)))
if tasks:
try:
await asyncio.gather(*tasks)
except Exception:
logger.exception(f"Exception occurred during pre-tasks for change stage in timer {self!r}")
print("Sending Status")
await self.send_status()
print("Sent Status")
if after_tasks:
try:
await asyncio.gather(*after_tasks)
except Exception:
logger.exception(f"Exception occurred during post-tasks for change stage in timer {self!r}")
@log_wrap(action='Voice Alert')
async def _voice_alert(self, stage: Stage):
"""
Join the voice channel, play the associated alert, and leave the channel.
"""
if not stage:
return
if not self.channel.permissions_for(self.guild.me).speak:
return
async with self.lguild.voice_lock:
try:
if self.guild.voice_client:
print("Disconnecting")
await self.guild.voice_client.disconnect(force=True)
print("Disconnected")
alert_file = focus_alert_path if stage.focused else break_alert_path
try:
print("Connecting")
voice_client = await self.channel.connect(timeout=60, reconnect=False)
print("Connected")
except asyncio.TimeoutError:
logger.warning(f"Timed out while connecting to voice channel in timer {self!r}")
return
with open(alert_file, 'rb') as audio_stream:
finished = asyncio.Event()
def voice_callback(error):
if error:
try:
raise error
except Exception:
logger.exception(
f"Callback exception occured while playing voice alert for timer {self!r}"
)
finished.set()
voice_client.play(discord.PCMAudio(audio_stream), after=voice_callback)
# Quit when we finish playing or after 10 seconds, whichever comes first
sleep_task = asyncio.create_task(asyncio.sleep(10))
wait_task = asyncio.create_task(finished.wait())
_, pending = await asyncio.wait([sleep_task, wait_task], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await self.guild.voice_client.disconnect(force=True)
except Exception:
logger.exception(
"Exception occurred while playing voice alert for timer {self!r}"
)
def stageline(self, stage: Stage):
t = self.bot.translator.t
ctx_locale.set(self.locale.value)
if stage.focused:
lazy_stageline = _p(
'timer|status|stage:focus|statusline',
"{channel} is now in **FOCUS**! Good luck, **BREAK** starts {timestamp}"
)
else:
lazy_stageline = _p(
'timer|status|stage:break|statusline',
"{channel} is now on **BREAK**! Take a rest, **FOCUS** starts {timestamp}"
)
stageline = t(lazy_stageline).format(
channel=self.channel.mention,
timestamp=f"<t:{int(stage.end.timestamp())}:R>"
)
return stageline
async def current_status(self, with_notify=True, with_warnings=True) -> MessageArgs:
"""
Message arguments for the current timer status message.
"""
t = self.bot.translator.t
now = utc_now()
ctx_locale.set(self.locale.value)
stage = self.current_stage
if self.running:
stageline = self.stageline(stage)
warningline = ""
needs_warning = []
if with_warnings and self.inactivity_threshold > 0:
threshold = self.warning_threshold(stage)
for member in self.members:
last_seen = self.last_seen.get(member.id, None)
if last_seen is None:
last_seen = self.last_seen[member.id] = now
elif last_seen < threshold:
needs_warning.append(member)
if needs_warning:
warningline = t(_p(
'timer|status|warningline',
"**Warning:** {mentions}, please press {tick} to avoid being removed on the next stage."
)).format(
mentions=' '.join(member.mention for member in needs_warning),
tick=self.bot.config.emojis.tick
)
if with_notify and self.members:
# TODO: Handle case with too many members
notifyline = ''.join(member.mention for member in self.members if member not in needs_warning)
notifyline = f"||{notifyline}||"
else:
notifyline = ""
content = "\n".join(string for string in (stageline, warningline, notifyline) if string)
elif self.auto_restart:
content = t(_p(
'timer|status|stopped:auto',
"Timer stopped! Join {channel} to start the timer."
)).format(channel=self.channel.mention)
else:
content = t(_p(
'timer|status|stopped:manual',
"Timer stopped! Press `Start` to restart the timer."
)).format(channel=self.channel.mention)
card = await get_timer_card(self.bot, self, stage)
await card.render()
if (ui := self.status_view) is None:
ui = self.status_view = TimerStatusUI(self.bot, self, self.channel)
await ui.refresh()
return MessageArgs(
content=content,
file=card.as_file(f"pomodoro_{self.data.channelid}.png"),
view=ui
)
@log_wrap(action='Send Timer Status')
async def send_status(self, delete_last=True, **kwargs):
"""
Send a new status card to the notification channel.
"""
notify_hook = await self.get_notification_webhook()
if not notify_hook:
return
# Delete last notification message if possible
last_message_id = self.data.last_messageid
if delete_last and last_message_id:
try:
if self.last_status_message:
await self.last_status_message.delete()
else:
await notify_hook.delete_message(last_message_id)
except discord.HTTPException:
logger.debug(
f"Timer {self!r} failed to delete last status message {last_message_id}"
)
last_message_id = None
self.last_status_message = None
# Send new notification message
args = await self.current_status(**kwargs)
logger.debug(
f"Timer {self!r} is sending a new status: {args.send_args}"
)
try:
message = await notify_hook.send(**args.send_args, wait=True)
last_message_id = message.id
self.last_status_message = message
except discord.NotFound:
if self._hook is not None:
await self._hook.delete()
self._hook = None
# To avoid killing the client on an infinite loop (which should be impossible)
await asyncio.sleep(1)
await self.send_status(delete_last, **kwargs)
except discord.HTTPException:
pass
# Save last message id
if last_message_id != self.data.last_messageid:
await self.data.update(last_messageid=last_message_id)
@log_wrap(action='Update Timer Status')
async def update_status_card(self, **kwargs):
"""
Update the last status card sent.
"""
async with self._lock:
args = await self.current_status(**kwargs)
logger.debug(
f"Timer {self!r} is updating last status with new status: {args.edit_args}"
)
last_message = self.last_status_message
if last_message is None and self.data.last_messageid is not None:
# Attempt to retrieve previous message
notify_hook = await self.get_notification_webhook()
try:
if notify_hook:
last_message = await notify_hook.fetch_message(self.data.last_messageid)
except discord.HTTPException:
last_message = None
self.last_status_message = None
except Exception:
logger.exception(
f"Unhandled exception while updating timer last status for timer {self!r}"
)
repost = last_message is None
if not repost:
try:
await last_message.edit(**args.edit_args)
self.last_status_message = last_message
except discord.NotFound:
repost = True
except discord.HTTPException:
# Unexpected issue with sending the status message
logger.exception(
f"Exception occurred updating status for Timer {self!r}"
)
if repost:
await self.send_status(delete_last=False, with_notify=False)
async def _update_channel_name(self):
"""
Submit a task to update the voice channel name.
Attempts to ensure that only one task is running at a time.
Attempts to wait until the next viable channel update slot (via ratelimit).
"""
if self._voice_update_task and not self._voice_update_task.done():
# Voice update request already submitted
return
async with self._voice_update_lock:
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
if not self.channel:
return
if not self.channel.permissions_for(self.guild.me).manage_channels:
return
new_name = self.channel_name
if new_name == self.channel.name:
return
self._last_voice_update = utc_now()
await self.channel.edit(name=self.channel_name)
@log_wrap(action="Stop Timer")
async def stop(self, auto_restart=False):
"""
Stop the timer.
Stops the run loop, and updates the last status message to a stopped message.
"""
try:
async with self._lock:
if self._run_task and not self._run_task.done():
self._run_task.cancel()
await self.data.update(last_started=None, auto_restart=auto_restart)
await self.update_status_card()
except Exception:
logger.exception(f"Exception while stopping Timer {self!r}!")
else:
logger.info(f"Timer {self!r} has stopped. Auto restart is {'on' if auto_restart else 'off'}")
@log_wrap(action="Destroy Timer")
async def destroy(self, reason: str = None):
"""
Deconstructs the timer, stopping all tasks.
"""
async with self._lock:
if self._run_task and not self._run_task.done():
self._run_task.cancel()
channelid = self.data.channelid
await self.data.delete()
if self.last_status_message:
try:
await self.last_status_message.delete()
except discord.HTTPException:
pass
logger.info(
f"Timer <tid: {channelid}> deleted. Reason given: {reason!r}"
)
@log_wrap(stack=['Timer Loop'])
async def _runloop(self):
"""
Main loop which controls the
regular stage changes and status updates.
"""
# Allow updating with 10 seconds of drift to the next stage change
drift = 10
if not self.running:
# Nothing to do
return
if not self.channel:
# Underlying Discord objects do not exist, destroy the timer.
await self.destroy(reason="Underlying channel no longer exists.")
return
background_tasks = set()
self._state = current = self.current_stage
while True:
to_next_stage = (current.end - utc_now()).total_seconds()
# TODO: Consider request rate and load
if to_next_stage > 1 * 60 - drift:
time_to_sleep = 1 * 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 not self.running:
# We somehow stopped without cancelling the run task?
logger.warning(
f"Closing timer loop because we are no longer running. This should not happen! Timer {self!r}"
)
break
if not self.channel:
# Probably left the guild or the channel was deleted
await self.destroy(reason="Underlying channel no longer exists")
break
if current.end < utc_now():
self._state = self.current_stage
task = asyncio.create_task(self.notify_change_stage(current, self._state))
task.add_done_callback(background_tasks.discard)
current = self._state
elif self.members:
task = asyncio.create_task(self._update_channel_name())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
task = asyncio.create_task(self.update_status_card())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
if background_tasks:
await asyncio.gather(*background_tasks)
def launch(self):
"""
Launch the update loop, if the timer is running, otherwise do nothing.
"""
if self._loop_task and not self._loop_task.done():
self._loop_task.cancel()
if self.running:
self._loop_task = asyncio.create_task(self._runloop())
async def unload(self):
"""
Unload the timer without changing stored state.
Waits for all background tasks to complete.
"""
async with self._lock:
if self._loop_task and not self._loop_task.done():
if self._run_task and not self._run_task.done():
self._run_task.cancel()
await self._loop_task