Compare commits

...

6 Commits

Author SHA1 Message Date
93c17112e3 Switch to explicit allowed prefix list. 2025-11-07 23:50:02 +10:00
26dc684851 Fix typo in link 2025-11-01 11:53:42 +10:00
1bd2e9607a fix: Add lock to prevent update race. 2025-11-01 11:49:27 +10:00
a14cc4056e fix: Channel del userid type 2025-11-01 11:41:10 +10:00
3885710689 feat: Add websocket channel 2025-11-01 10:58:26 +10:00
737871d696 Add invite link 2025-11-01 07:21:40 +10:00
2 changed files with 203 additions and 46 deletions

107
hyperfocus/channel.py Normal file
View File

@@ -0,0 +1,107 @@
from collections import defaultdict
from typing import Optional, TypeAlias, TypedDict
import json
from datetime import datetime, timedelta
from meta.sockets import Channel
from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger
from .data import Hyperfocuser, HyperfocusData
ISOTimestamp: TypeAlias = str
class HyperfocusedPayload(TypedDict):
userid: str
user_name: str
started_at: ISOTimestamp
ends_at: ISOTimestamp
async def prepare_hyperfocuser(profiler: ProfilesRegistry, hyperfocuser: Hyperfocuser):
profile = await profiler.get_profile(hyperfocuser.profileid)
assert profile is not None
return HyperfocusedPayload(
userid=str(hyperfocuser.profileid),
user_name=profile.nickname or "Unknown",
started_at=hyperfocuser.started_at.isoformat(),
ends_at=hyperfocuser.ends_at.isoformat(),
)
class FocusChannel(Channel):
name = "HyperFocus"
def __init__(self, profiler: ProfilesRegistry, focusdata: HyperfocusData, **kwargs):
super().__init__(**kwargs)
self.profiler = profiler
self.focusdata = focusdata
# communityid -> listening websockets
self.communities = defaultdict(set)
async def on_connection(self, websocket, event):
if not (cidstr := event.get("community")):
raise ValueError("Hyperfocus browser missing communityid")
elif not cidstr.isdigit():
raise ValueError("Community id provided is not an integer")
cid = int(cidstr)
community = await self.profiler.get_community(cid)
if community is None:
raise ValueError("Unknown community provided")
await super().on_connection(websocket, event)
self.communities[cid].add(websocket)
focus_rows = await Hyperfocuser.fetch_where(started_in=cid)
await self.send_hyperfocus_put(cid, focus_rows)
async def del_connection(self, websocket):
for wss in self.communities.values():
wss.discard(websocket)
await super().del_connection(websocket)
async def send_hyperfocus_patch(
self, communityid: int, focuser: Hyperfocuser, websocket=None
):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
"type": "DO",
"method": "patchFocus",
"args": await prepare_hyperfocuser(self.profiler, focuser),
},
websocket=ws,
)
async def send_hyperfocus_del(self, profileid: int, websocket=None):
await self.send_event(
{
"type": "DO",
"method": "delFocus",
"args": {"userid": str(profileid)},
},
websocket=websocket,
)
async def send_hyperfocus_put(
self, communityid: int, focusers: list[Hyperfocuser], websocket=None
):
payload = []
for focuser in focusers:
fpl = await prepare_hyperfocuser(self.profiler, focuser)
payload.append(fpl)
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{"type": "DO", "method": "putFocus", "args": payload},
websocket=ws,
)
async def send_event(self, event, **kwargs):
logger.info(f"Sending websocket event: {json.dumps(event, indent=1)}")
await super().send_event(event, **kwargs)

View File

@@ -1,3 +1,4 @@
import asyncio
from string import punctuation from string import punctuation
import datetime as dt import datetime as dt
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -11,11 +12,13 @@ from twitchio.ext import commands as cmds
from botdata import UserAuth from botdata import UserAuth
from meta import Bot from meta import Bot
from meta.logger import log_wrap from meta.logger import log_wrap
from meta.sockets import register_channel
from utils.lib import parse_dur, strfdelta, utc_now from utils.lib import parse_dur, strfdelta, utc_now
from . import logger from . import logger
from ..data import HyperfocusData, Hyperfocuser from ..data import HyperfocusData, Hyperfocuser
from ..channel import FocusChannel
# Default requested scopes for joining a channel # Default requested scopes for joining a channel
@@ -33,8 +36,12 @@ class FocusComponent(cmds.Component):
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())
self.channel = FocusChannel(self.bot.profiles.profiles, self.data)
register_channel(self.channel.name, self.channel)
self._last_deleted: dict[int, datetime] = {} self._last_deleted: dict[int, datetime] = {}
self.hyperfocus_lock = asyncio.Lock()
# ----- API ----- # ----- API -----
async def component_load(self): async def component_load(self):
@@ -65,7 +72,7 @@ class FocusComponent(cmds.Component):
This amounts to whether it starts with a punctuation symbol, or it is only emotes and mentions. This amounts to whether it starts with a punctuation symbol, or it is only emotes and mentions.
""" """
allowed = message.text.startswith(tuple(punctuation)) allowed = message.text.startswith(tuple("!*#%|?><."))
if not allowed: if not allowed:
allowed = True allowed = True
@@ -83,6 +90,10 @@ class FocusComponent(cmds.Component):
@cmds.Component.listener() @cmds.Component.listener()
async def event_message(self, payload: twitchio.ChatMessage): async def event_message(self, payload: twitchio.ChatMessage):
async with self.hyperfocus_lock:
await self.handle_message(payload)
async def handle_message(self, payload: twitchio.ChatMessage):
# Check if chatter is currently hyperfocused # Check if chatter is currently hyperfocused
profile = await self.bot.profiles.fetch_profile(payload.chatter, touch=True) profile = await self.bot.profiles.fetch_profile(payload.chatter, touch=True)
hyperfocused = await self.get_hyperfocus(profile.profileid) hyperfocused = await self.get_hyperfocus(profile.profileid)
@@ -96,22 +107,41 @@ class FocusComponent(cmds.Component):
) )
try: try:
await self.focus_delete_message(payload) await self.focus_delete_message(payload)
if notify: deleted = True
await payload.broadcaster.send_message(
f"@{payload.chatter.name} Stay focused! "
"(You are in !hyperfocus, use !unfocus to come back if you need to!)",
sender=self.bot.bot_id,
)
except Exception: except Exception:
logger.warning( logger.warning(
f"Failed to delete a hyperfocus message: {payload!r}", exc_info=True f"Failed to delete a hyperfocus message: {payload!r}", exc_info=True
) )
if notify: deleted = False
await payload.broadcaster.send_message(
f"@{payload.chatter.name} Stay focused! ", if notify:
sender=self.bot.bot_id, self._last_deleted[profile.profileid] = utc_now()
try:
if deleted:
await payload.broadcaster.send_message(
f"@{payload.chatter.name} Stay focused! "
"(You are in !hyperfocus, use !unfocus to come back if you need to!)",
sender=self.bot.bot_id,
)
else:
await payload.broadcaster.send_message(
f"@{payload.chatter.name} Stay focused! ",
sender=self.bot.bot_id,
)
except twitchio.exceptions.HTTPException:
logger.warning(
f"Failed to notify user of hyperfocus deletion: {payload!r}",
exc_info=True,
) )
self._last_deleted[profile.profileid] = utc_now()
if hyperfocused:
# Send an update to the channel
# TODO: Nicer and more efficient caching of which channel has which users
# TODO: Possible race condition with the commands. Should use locks.
comm = await self.bot.profiles.fetch_community(
payload.broadcaster, touch=True
)
await self.channel.send_hyperfocus_patch(comm.communityid, hyperfocused)
# ------ Commands ----- # ------ Commands -----
@cmds.command( @cmds.command(
@@ -149,33 +179,37 @@ class FocusComponent(cmds.Component):
pid = profile.profileid pid = profile.profileid
comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True) comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True)
await Hyperfocuser.table.delete_where(profileid=pid) async with self.hyperfocus_lock:
await Hyperfocuser.create( await Hyperfocuser.table.delete_where(profileid=pid)
profileid=pid, focuser = await Hyperfocuser.create(
started_at=now, profileid=pid,
ends_at=end_at, started_at=now,
started_in=comm.communityid, ends_at=end_at,
) started_in=comm.communityid,
)
# TODO: Update channel await self.channel.send_hyperfocus_patch(comm.communityid, focuser)
minutes = ceil(dur / 60)
await ctx.reply( minutes = ceil(dur / 60)
f"{ctx.chatter.name} has gone into HYPERFOCUS! " await ctx.reply(
f"They will be in emote and command only mode for the next {minutes} minutes! " f"{ctx.chatter.name} has gone into HYPERFOCUS! "
"Use !unfocus to come back if you need to, best of luck! ☘️🍀☘️ " 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") @cmds.command(name="unfocus")
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)
row = await Hyperfocuser.fetch(profile.profileid) async with self.hyperfocus_lock:
if row: row = await Hyperfocuser.fetch(profile.profileid)
await row.delete() if row:
await row.delete()
await self.channel.send_hyperfocus_del(profile.profileid)
await ctx.reply( await ctx.reply(
"Welcome back from focus, hope it went well!" "Welcome back from focus, hope it went well!"
" Remember to have a sip and stretch if you need it~" " Remember to have a sip and stretch if you need it~"
) )
@cmds.command(name="hyperfocused") @cmds.command(name="hyperfocused")
async def hyperfocused_cmd(self, ctx, user: twitchio.User | None = None): async def hyperfocused_cmd(self, ctx, user: twitchio.User | None = None):
@@ -183,16 +217,32 @@ class FocusComponent(cmds.Component):
profile = await self.bot.profiles.fetch_profile(user, touch=False) profile = await self.bot.profiles.fetch_profile(user, touch=False)
if hyper := (await self.get_hyperfocus(profile.profileid)): async with self.hyperfocus_lock:
durstr = strfdelta(hyper.ends_at - utc_now()) if hyper := (await self.get_hyperfocus(profile.profileid)):
await ctx.reply( durstr = strfdelta(hyper.ends_at - utc_now())
f"{user.name} is in HYPERFOCUS for another {durstr}! " await ctx.reply(
"They can only write emojis and commands in this time. Good luck!" f"{user.name} is in HYPERFOCUS for another {durstr}! "
) "They can only write emojis and commands in this time. Good luck!"
elif own: )
await ctx.reply( elif own:
"You are not hyperfocused!" await ctx.reply(
" Enter HYPERFOCUS mode for e.g. 10 minutes with '!hyperfocus 10'" "You are not hyperfocused!"
) " Enter HYPERFOCUS mode for e.g. 10 minutes with '!hyperfocus 10'"
else: )
await ctx.reply(f"{user.name} is not hyperfocused!") else:
await ctx.reply(f"{user.name} is not hyperfocused!")
@cmds.command(name="addfocus")
async def addfocus_cmd(self, ctx):
await ctx.reply(
"Add HYPERFOCUS to your channel by authorising me here: https://croccyfocus.thewisewolf.dev/invite"
)
@cmds.command(name="focuslist")
@cmds.is_moderator()
async def focuslist_cmd(self, ctx):
comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True)
link = (
f"https://croccyfocus.thewisewolf.dev/widget/?community={comm.communityid}"
)
await ctx.reply(f"Browser source link for your channel's hyperfocus: {link}")