Compare commits
8 Commits
baeea1c41b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 93c17112e3 | |||
| 26dc684851 | |||
| 1bd2e9607a | |||
| a14cc4056e | |||
| 3885710689 | |||
| 737871d696 | |||
| 41ec25cd21 | |||
| 424f7584cb |
107
hyperfocus/channel.py
Normal file
107
hyperfocus/channel.py
Normal 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)
|
||||||
@@ -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)
|
||||||
@@ -91,27 +102,46 @@ class FocusComponent(cmds.Component):
|
|||||||
if hyperfocused and not self.check_hyperfocus_message(payload):
|
if hyperfocused and not self.check_hyperfocus_message(payload):
|
||||||
# If we need to delete, run delete and send message
|
# If we need to delete, run delete and send message
|
||||||
notify = ( #
|
notify = ( #
|
||||||
(last := self._last_deleted.get(profile.profileid))
|
not (last := self._last_deleted.get(profile.profileid))
|
||||||
and (utc_now() - last).total_seconds() > 30
|
or (utc_now() - last).total_seconds() > 30
|
||||||
)
|
)
|
||||||
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,31 +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.table.delete_where(profileid=profile.profileid)
|
async with self.hyperfocus_lock:
|
||||||
|
row = await Hyperfocuser.fetch(profile.profileid)
|
||||||
|
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):
|
||||||
@@ -181,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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user