forked from HoloTech/hyperfocus-plugin
Compare commits
5 Commits
d2050ab2dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c31cbc5b2 | |||
| d706e35fdf | |||
| 9c834c1c4e | |||
| 2cba5d0bbf | |||
| 869b401314 |
+1
-1
@@ -4,7 +4,7 @@ from data.columns import String, Integer, Timestamp
|
|||||||
|
|
||||||
class Hyperfocuser(RowModel):
|
class Hyperfocuser(RowModel):
|
||||||
_tablename_ = "hyperfocused"
|
_tablename_ = "hyperfocused"
|
||||||
_cache_ = {}
|
# _cache_ = {}
|
||||||
|
|
||||||
profileid = Integer(primary=True)
|
profileid = Integer(primary=True)
|
||||||
started_at = Timestamp()
|
started_at = Timestamp()
|
||||||
|
|||||||
+221
-18
@@ -2,9 +2,11 @@ import asyncio
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from string import punctuation
|
from string import punctuation
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from math import ceil
|
from math import ceil
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import twitchio
|
import twitchio
|
||||||
from twitchio import Scopes
|
from twitchio import Scopes
|
||||||
@@ -32,8 +34,98 @@ CHANNEL_SCOPES = Scopes(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
responses = """
|
||||||
|
🐱 Nice try. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
❌ This interaction has been reviewed and rejected. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
🚨 Nico has reviewed your message and found it unnecessary. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
📄 Nico gently places your message in the shredder. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
🧐 Nico believes your future self would rather you keep focusing. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
💭 That thought can wait until after the focus session. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
⚠️ Unauthorized yapping detected. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
🚫 Nico has denied your request to be distracted. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
❌ Nico recommends focusing instead. (You are in !hyperfocus. Use !unfocus if needed 🐾)
|
||||||
|
""".splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
class FocusNotifier:
|
||||||
|
def __init__(self, channel: twitchio.PartialUser, send_as: str):
|
||||||
|
# Users which have their focus end in the next 10s
|
||||||
|
self.prenotify_queue: dict[str, datetime] = {}
|
||||||
|
|
||||||
|
# Users which have already had their focus end, waiting for prenotified
|
||||||
|
self.notify_queue: list[str] = []
|
||||||
|
|
||||||
|
# Task which moves prenotify to notify, and either consumes and notifies
|
||||||
|
# or sleeps and reschedules
|
||||||
|
self.notify_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# Channel which we can send to, to notify
|
||||||
|
self.channel: twitchio.PartialUser = channel
|
||||||
|
self.send_as = send_as
|
||||||
|
|
||||||
|
# TODO: Locking? Ensure we never lose a notify task?
|
||||||
|
|
||||||
|
def focusendingsoon(self, username: str, focusends: datetime):
|
||||||
|
logger.debug(f"Prenotify for {username=} at {focusends=}")
|
||||||
|
self.prenotify_queue[username] = focusends
|
||||||
|
self.schedule_notify()
|
||||||
|
|
||||||
|
def schedule_notify(self):
|
||||||
|
# Idempotently schedule notification
|
||||||
|
if (
|
||||||
|
self.notify_task is None
|
||||||
|
or self.notify_task.cancelled()
|
||||||
|
or self.notify_task.done()
|
||||||
|
):
|
||||||
|
self.notify_task = asyncio.create_task(self._notify())
|
||||||
|
|
||||||
|
async def _notify(self):
|
||||||
|
# TODO: Logging
|
||||||
|
# The notify task
|
||||||
|
# Move all expired prenotify to notify queue
|
||||||
|
logger.debug(
|
||||||
|
f"Scheduling notify task: {self.prenotify_queue=} {self.notify_queue=}"
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
for username, ending in list(self.prenotify_queue.items()):
|
||||||
|
if ending <= now:
|
||||||
|
self.prenotify_queue.pop(username)
|
||||||
|
self.notify_queue.append(username)
|
||||||
|
|
||||||
|
# If there are any prenotifies left
|
||||||
|
if self.prenotify_queue:
|
||||||
|
# Wait until troubleshooting prenotify + 1 and rerun
|
||||||
|
sleep_until = max(self.prenotify_queue.values()) + dt.timedelta(
|
||||||
|
milliseconds=500
|
||||||
|
)
|
||||||
|
await asyncio.sleep((sleep_until - now).total_seconds())
|
||||||
|
|
||||||
|
# This is sync, no possible race conditions
|
||||||
|
self.notify_task = None
|
||||||
|
self.schedule_notify()
|
||||||
|
else:
|
||||||
|
# If there aren't
|
||||||
|
# Set the notify_task to None to indicate we are done waiting
|
||||||
|
# Then any new prenotifies will schedule a new one
|
||||||
|
self.notify_task = None
|
||||||
|
# Consume notify queue and actually notify
|
||||||
|
if self.notify_queue:
|
||||||
|
names = tuple(self.notify_queue)
|
||||||
|
self.notify_queue.clear()
|
||||||
|
await self._actually_notify(*names)
|
||||||
|
|
||||||
|
async def _actually_notify(self, *usernames):
|
||||||
|
# Send message to channel with given names
|
||||||
|
namestr = ", ".join(usernames)
|
||||||
|
message = f"🚨 {namestr} your HYPERFOCUS lockdown is complete. Nico approves. kawaii16Hype ✨"
|
||||||
|
await self.channel.send_message(message, self.send_as)
|
||||||
|
|
||||||
|
|
||||||
class FocusComponent(cmds.Component):
|
class FocusComponent(cmds.Component):
|
||||||
|
# If user's focus sessions end within ~10 seconds of each other, notify at the same time
|
||||||
|
_notify_buffer = 10
|
||||||
|
_last_seen_threshold = 4 * 3600
|
||||||
|
|
||||||
def __init__(self, bot: Bot):
|
def __init__(self, bot: Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.data = bot.dbconn.load_registry(HyperfocusData())
|
self.data = bot.dbconn.load_registry(HyperfocusData())
|
||||||
@@ -43,12 +135,82 @@ class FocusComponent(cmds.Component):
|
|||||||
|
|
||||||
self._last_seen: dict[int, dict[int, datetime]] = defaultdict(dict)
|
self._last_seen: dict[int, dict[int, datetime]] = defaultdict(dict)
|
||||||
self._last_deleted: dict[int, datetime] = {}
|
self._last_deleted: dict[int, datetime] = {}
|
||||||
|
|
||||||
|
# Map of userid -> (focus end, focus preending task)
|
||||||
|
self._focus_preending: dict[int, asyncio.Task] = {}
|
||||||
|
self._channel_notifiers: dict[int, FocusNotifier] = {}
|
||||||
|
self._cid_channel_cache: dict[int, twitchio.PartialUser] = {}
|
||||||
|
|
||||||
self.hyperfocus_lock = asyncio.Lock()
|
self.hyperfocus_lock = asyncio.Lock()
|
||||||
|
|
||||||
# ----- API -----
|
# ----- API -----
|
||||||
async def component_load(self):
|
async def component_load(self):
|
||||||
await self.data.init()
|
await self.data.init()
|
||||||
|
|
||||||
|
def schedule_focusend_notify(
|
||||||
|
self, focuser: Hyperfocuser, initial_cid: int | None = None
|
||||||
|
):
|
||||||
|
# Cancel current focus preend if it exists
|
||||||
|
self.cancel_focusend_notify(focuser.profileid)
|
||||||
|
|
||||||
|
# Schedule a new one
|
||||||
|
task = asyncio.create_task(self._preend_notify_task(focuser, initial_cid))
|
||||||
|
self._focus_preending[focuser.profileid] = task
|
||||||
|
|
||||||
|
def cancel_focusend_notify(self, profileid):
|
||||||
|
maybetask = self._focus_preending.pop(profileid, None)
|
||||||
|
if maybetask and not maybetask.done() and not maybetask.cancelled():
|
||||||
|
maybetask.cancel()
|
||||||
|
|
||||||
|
async def _preend_notify_task(
|
||||||
|
self, focuser: Hyperfocuser, initial_cid: int | None = None
|
||||||
|
):
|
||||||
|
logger.debug(f"Scheduling prenotify for {focuser=}")
|
||||||
|
prenotify_at = focuser.ends_at - dt.timedelta(seconds=self._notify_buffer)
|
||||||
|
now = utc_now()
|
||||||
|
if prenotify_at > now:
|
||||||
|
await asyncio.sleep((prenotify_at - now).total_seconds())
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
# In case the focuser was modified by another client
|
||||||
|
profileid = focuser.profileid
|
||||||
|
profile = await self.bot.profiles.profiles.get_profile(profileid)
|
||||||
|
maybefocuser = await self.get_hyperfocus(profileid)
|
||||||
|
|
||||||
|
# Note this is all sync past here, so no danger of race condition with cancelling the notify
|
||||||
|
self._focus_preending.pop(profileid)
|
||||||
|
if not maybefocuser:
|
||||||
|
# Focus ended prematurely, so we do as well
|
||||||
|
return
|
||||||
|
if maybefocuser.ends_at - dt.timedelta(seconds=self._notify_buffer) >= now:
|
||||||
|
self.schedule_focusend_notify(maybefocuser, initial_cid=initial_cid)
|
||||||
|
else:
|
||||||
|
# Trigger the prenotify
|
||||||
|
# (it will go through anyway)
|
||||||
|
logger.debug(f"Prenotifying for {maybefocuser=}")
|
||||||
|
targets = set()
|
||||||
|
if initial_cid is not None:
|
||||||
|
targets.add(initial_cid)
|
||||||
|
|
||||||
|
last_seen_cids = self._last_seen[profileid].items()
|
||||||
|
for cid, last_seen in last_seen_cids:
|
||||||
|
if (now - last_seen).total_seconds() < self._last_seen_threshold:
|
||||||
|
targets.add(cid)
|
||||||
|
logger.debug(f"Prenotifying for {maybefocuser=} in {targets=}")
|
||||||
|
for cid in targets:
|
||||||
|
notifier = self._channel_notifiers.get(cid)
|
||||||
|
if notifier is None:
|
||||||
|
channel = self._cid_channel_cache.get(cid)
|
||||||
|
if channel is not None:
|
||||||
|
notifier = FocusNotifier(channel, send_as=self.bot.bot_id)
|
||||||
|
self._channel_notifiers[cid] = notifier
|
||||||
|
if notifier is not None:
|
||||||
|
notifier.focusendingsoon(
|
||||||
|
username=profile.nickname or "Unknown",
|
||||||
|
focusends=maybefocuser.ends_at,
|
||||||
|
)
|
||||||
|
|
||||||
async def dispatch_focuser_update(
|
async def dispatch_focuser_update(
|
||||||
self,
|
self,
|
||||||
profileid: int,
|
profileid: int,
|
||||||
@@ -72,14 +234,18 @@ class FocusComponent(cmds.Component):
|
|||||||
now = utc_now()
|
now = utc_now()
|
||||||
|
|
||||||
if focuser is not None:
|
if focuser is not None:
|
||||||
|
self.schedule_focusend_notify(focuser, initial_cid=communityid)
|
||||||
|
|
||||||
# If we are sending active, send to all last seens newer than a certain date, and delete any old ones
|
# If we are sending active, send to all last seens newer than a certain date, and delete any old ones
|
||||||
last_seen_cids = self._last_seen[profileid].items()
|
last_seen_cids = self._last_seen[profileid].items()
|
||||||
for cid, last_seen in last_seen_cids:
|
for cid, last_seen in last_seen_cids:
|
||||||
if (now - last_seen).total_seconds() < 4 * 3600:
|
if (now - last_seen).total_seconds() < self._last_seen_threshold:
|
||||||
targets.add(cid)
|
targets.add(cid)
|
||||||
for cid in targets:
|
for cid in targets:
|
||||||
await self.channel.send_hyperfocus_patch(cid, focuser)
|
await self.channel.send_hyperfocus_patch(cid, focuser)
|
||||||
else:
|
else:
|
||||||
|
self.cancel_focusend_notify(profileid)
|
||||||
|
|
||||||
# If we are deleting, send to *all* last seens, and then delete any old ones
|
# If we are deleting, send to *all* last seens, and then delete any old ones
|
||||||
# targets.update(self._last_seen[profileid].keys())
|
# targets.update(self._last_seen[profileid].keys())
|
||||||
# for cid in targets:
|
# for cid in targets:
|
||||||
@@ -140,6 +306,9 @@ class FocusComponent(cmds.Component):
|
|||||||
|
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
self._last_seen[profile.profileid][comm.communityid] = now
|
self._last_seen[profile.profileid][comm.communityid] = now
|
||||||
|
self._cid_channel_cache[comm.communityid] = (
|
||||||
|
payload.broadcaster
|
||||||
|
) # TODO: Better last seen channel info
|
||||||
|
|
||||||
hyperfocused = await self.get_hyperfocus(profile.profileid)
|
hyperfocused = await self.get_hyperfocus(profile.profileid)
|
||||||
|
|
||||||
@@ -161,16 +330,17 @@ class FocusComponent(cmds.Component):
|
|||||||
|
|
||||||
if notify:
|
if notify:
|
||||||
self._last_deleted[profile.profileid] = now
|
self._last_deleted[profile.profileid] = now
|
||||||
|
response_text = random.choice(responses)
|
||||||
|
response_text = payload.chatter.mention + " " + response_text
|
||||||
try:
|
try:
|
||||||
if deleted:
|
if deleted:
|
||||||
await payload.broadcaster.send_message(
|
await payload.broadcaster.send_message(
|
||||||
f"@{payload.chatter.name} Stay focused! "
|
response_text,
|
||||||
"(You are in !hyperfocus, use !unfocus to come back if you need to!)",
|
|
||||||
sender=self.bot.bot_id,
|
sender=self.bot.bot_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await payload.broadcaster.send_message(
|
await payload.broadcaster.send_message(
|
||||||
f"@{payload.chatter.name} Stay focused! ",
|
response_text,
|
||||||
sender=self.bot.bot_id,
|
sender=self.bot.bot_id,
|
||||||
)
|
)
|
||||||
except twitchio.exceptions.HTTPException:
|
except twitchio.exceptions.HTTPException:
|
||||||
@@ -182,6 +352,7 @@ class FocusComponent(cmds.Component):
|
|||||||
if hyperfocused:
|
if hyperfocused:
|
||||||
# Send an update to the channel
|
# Send an update to the channel
|
||||||
await self.channel.send_hyperfocus_patch(comm.communityid, hyperfocused)
|
await self.channel.send_hyperfocus_patch(comm.communityid, hyperfocused)
|
||||||
|
self.schedule_focusend_notify(hyperfocused, initial_cid=comm.communityid)
|
||||||
|
|
||||||
# ------ Commands -----
|
# ------ Commands -----
|
||||||
@cmds.command(
|
@cmds.command(
|
||||||
@@ -233,23 +404,21 @@ class FocusComponent(cmds.Component):
|
|||||||
|
|
||||||
minutes = ceil(dur / 60)
|
minutes = ceil(dur / 60)
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
f"{ctx.chatter.name} has gone into HYPERFOCUS! "
|
f"🚨⚠️ Nico has decided distractions are illegal and activated HYPERFOCUS MODE. {ctx.chatter.name} will be in emote and command only mode for the next {minutes} minutes. Use !unfocus at your own risk. Nico is watching. kawaii16Stab"
|
||||||
f"They will be in emote and command only mode for the next {minutes} minutes! "
|
|
||||||
"Use !unfocus to come back if you need to, best of luck! ☘️🍀☘️ "
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@cmds.command(name="unfocus", aliases=["uncrocus"])
|
@cmds.command(name="unfocus", aliases=["uncrocus", "unhyper", "unhyperfocus"])
|
||||||
async def unfocus_cmd(self, ctx):
|
async def unfocus_cmd(self, ctx):
|
||||||
profile = await self.bot.profiles.fetch_profile(ctx.chatter, touch=True)
|
profile = await self.bot.profiles.fetch_profile(ctx.chatter, touch=True)
|
||||||
async with self.hyperfocus_lock:
|
async with self.hyperfocus_lock:
|
||||||
row = await Hyperfocuser.fetch(profile.profileid)
|
row = await Hyperfocuser.fetch(profile.profileid)
|
||||||
if row:
|
if row:
|
||||||
|
self.cancel_focusend_notify(profile.profileid)
|
||||||
await row.delete()
|
await row.delete()
|
||||||
await self.channel.send_hyperfocus_del(profile.profileid)
|
await self.channel.send_hyperfocus_del(profile.profileid)
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
"Welcome back from focus, hope it went well!"
|
"Nico has reluctantly granted your request. Welcome back to chat. 🐱✨"
|
||||||
" Remember to have a sip and stretch if you need it~"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@cmds.command(name="hyperfocused")
|
@cmds.command(name="hyperfocused")
|
||||||
@@ -262,17 +431,53 @@ class FocusComponent(cmds.Component):
|
|||||||
if hyper := (await self.get_hyperfocus(profile.profileid)):
|
if hyper := (await self.get_hyperfocus(profile.profileid)):
|
||||||
durstr = strfdelta(hyper.ends_at - utc_now())
|
durstr = strfdelta(hyper.ends_at - utc_now())
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
f"{user.name} is in HYPERFOCUS for another {durstr}! "
|
f"🐾 {user.name} remains in HYPERFOCUS for another {durstr}!"
|
||||||
"They can only write emojis and commands in this time. Good luck!"
|
" Nico allows only commands and emotes. No exceptions. kawaii16Stab"
|
||||||
)
|
)
|
||||||
elif own:
|
elif own:
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
"You are not hyperfocused!"
|
"🧐 Nico looked for a focus session and found nothing."
|
||||||
" Enter HYPERFOCUS mode for e.g. 10 minutes with '!hyperfocus 10'"
|
" Suspicious. Enter HYPERFOCUS mode for e.g. 10 minutes with '!hyperfocus 10'"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await ctx.reply(f"{user.name} is not hyperfocused!")
|
await ctx.reply(f"{user.name} is not hyperfocused!")
|
||||||
|
|
||||||
|
@cmds.command(name="focuswith", aliases=["hyperfocuswith"])
|
||||||
|
async def focuswith_cmd(self, ctx, user: twitchio.User):
|
||||||
|
profile = await self.bot.profiles.fetch_profile(ctx.chatter, touch=False)
|
||||||
|
pid = profile.profileid
|
||||||
|
tprofile = await self.bot.profiles.fetch_profile(user, touch=False)
|
||||||
|
tpid = tprofile.profileid
|
||||||
|
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
if hyper := await self.get_hyperfocus(tpid):
|
||||||
|
async with self.hyperfocus_lock:
|
||||||
|
end_at = hyper.ends_at
|
||||||
|
|
||||||
|
# Update the row
|
||||||
|
comm = await self.bot.profiles.fetch_community(
|
||||||
|
ctx.broadcaster, touch=True
|
||||||
|
)
|
||||||
|
|
||||||
|
await Hyperfocuser.table.delete_where(profileid=pid)
|
||||||
|
focuser = await Hyperfocuser.create(
|
||||||
|
profileid=pid,
|
||||||
|
started_at=now,
|
||||||
|
ends_at=end_at,
|
||||||
|
started_in=comm.communityid,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.dispatch_focuser_update(pid, comm.communityid, focuser)
|
||||||
|
|
||||||
|
durstr = strfdelta(end_at - now)
|
||||||
|
await ctx.reply(
|
||||||
|
f"🐱 Nico locks {ctx.chatter.name} into HYPERFOCUS with {user.name} for {durstr}! "
|
||||||
|
"Procrastination is now a group offense. kawaii16Shoot"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.reply(f"{user.name} is not hyperfocused!")
|
||||||
|
|
||||||
@cmds.command(name="addfocus")
|
@cmds.command(name="addfocus")
|
||||||
async def addfocus_cmd(self, ctx):
|
async def addfocus_cmd(self, ctx):
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
@@ -282,9 +487,7 @@ class FocusComponent(cmds.Component):
|
|||||||
@cmds.command(name="focuslist")
|
@cmds.command(name="focuslist")
|
||||||
async def focuslist_cmd(self, ctx):
|
async def focuslist_cmd(self, ctx):
|
||||||
comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True)
|
comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True)
|
||||||
link = (
|
link = f"https://nicofocus.thewisewolf.dev/widget/?community={comm.communityid}"
|
||||||
f"https://croccyfocus.thewisewolf.dev/widget/?community={comm.communityid}"
|
|
||||||
)
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
f"Browser source link for your channel's hyperfocus: {link} (For troubleshooting: your community id is {comm.communityid})"
|
f"Browser source link for your channel's hyperfocus: {link} (For troubleshooting: your community id is {comm.communityid})"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user