Compare commits
11 Commits
61076ce933
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 93c17112e3 | |||
| 26dc684851 | |||
| 1bd2e9607a | |||
| a14cc4056e | |||
| 3885710689 | |||
| 737871d696 | |||
| 41ec25cd21 | |||
| 424f7584cb | |||
| baeea1c41b | |||
| 6eabbc0d73 | |||
| 82b78924a0 |
@@ -0,0 +1 @@
|
|||||||
|
from .hyperfocus import *
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO version_history (component, from_version, to_version, author)
|
||||||
|
VALUES ('HYPERFOCUS', 0, 1, 'Initial Creation');
|
||||||
|
|
||||||
|
CREATE TABLE hyperfocused(
|
||||||
|
profileid INTEGER PRIMARY KEY REFERENCES user_profiles(profileid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
started_in INTEGER REFERENCES communities(communityid) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from .twitch import *
|
||||||
|
|||||||
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)
|
||||||
16
hyperfocus/data.py
Normal file
16
hyperfocus/data.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from data import Registry, Table, RowModel
|
||||||
|
from data.columns import String, Integer, Timestamp
|
||||||
|
|
||||||
|
|
||||||
|
class Hyperfocuser(RowModel):
|
||||||
|
_tablename_ = "hyperfocused"
|
||||||
|
_cache_ = {}
|
||||||
|
|
||||||
|
profileid = Integer(primary=True)
|
||||||
|
started_at = Timestamp()
|
||||||
|
ends_at = Timestamp()
|
||||||
|
started_in = Integer()
|
||||||
|
|
||||||
|
|
||||||
|
class HyperfocusData(Registry):
|
||||||
|
hyperfocused = Hyperfocuser.table
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def twitch_setup(bot):
|
||||||
|
from .component import FocusComponent
|
||||||
|
|
||||||
|
await bot.add_component(FocusComponent(bot))
|
||||||
|
|||||||
248
hyperfocus/twitch/component.py
Normal file
248
hyperfocus/twitch/component.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import asyncio
|
||||||
|
from string import punctuation
|
||||||
|
import datetime as dt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from math import ceil
|
||||||
|
import time
|
||||||
|
|
||||||
|
import twitchio
|
||||||
|
from twitchio import Scopes
|
||||||
|
from twitchio.ext import commands as cmds
|
||||||
|
|
||||||
|
from botdata import UserAuth
|
||||||
|
from meta import Bot
|
||||||
|
from meta.logger import log_wrap
|
||||||
|
from meta.sockets import register_channel
|
||||||
|
from utils.lib import parse_dur, strfdelta, utc_now
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
from ..data import HyperfocusData, Hyperfocuser
|
||||||
|
from ..channel import FocusChannel
|
||||||
|
|
||||||
|
|
||||||
|
# Default requested scopes for joining a channel
|
||||||
|
CHANNEL_SCOPES = Scopes(
|
||||||
|
(
|
||||||
|
Scopes.channel_bot,
|
||||||
|
Scopes.user_read_chat,
|
||||||
|
Scopes.user_write_chat,
|
||||||
|
Scopes.moderator_manage_chat_messages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FocusComponent(cmds.Component):
|
||||||
|
def __init__(self, bot: Bot):
|
||||||
|
self.bot = bot
|
||||||
|
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.hyperfocus_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ----- API -----
|
||||||
|
async def component_load(self):
|
||||||
|
await self.data.init()
|
||||||
|
|
||||||
|
async def get_hyperfocus(self, profileid: int) -> Hyperfocuser | None:
|
||||||
|
"""
|
||||||
|
Get the Hyperfocuser if the user is hyperfocused.
|
||||||
|
"""
|
||||||
|
row = await Hyperfocuser.fetch(profileid)
|
||||||
|
if row and row.ends_at > utc_now():
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def focus_delete_message(self, message: twitchio.ChatMessage):
|
||||||
|
"""Delete the given message."""
|
||||||
|
# This should be impossible, but just in case.
|
||||||
|
# None id could cause chat to be wiped
|
||||||
|
assert message.id is not None
|
||||||
|
|
||||||
|
await message.broadcaster.delete_chat_messages(
|
||||||
|
moderator=message.broadcaster,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_hyperfocus_message(self, message: twitchio.ChatMessage):
|
||||||
|
"""
|
||||||
|
Check whether the given message is allowed to be sent in hyperfocus.
|
||||||
|
|
||||||
|
This amounts to whether it starts with a punctuation symbol, or it is only emotes and mentions.
|
||||||
|
"""
|
||||||
|
allowed = message.text.startswith(tuple("!*#%|?><."))
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
allowed = True
|
||||||
|
for fragment in message.fragments:
|
||||||
|
if allowed and fragment.type == "text":
|
||||||
|
stripped = fragment.text.strip().replace(" ", "").replace("\n", "")
|
||||||
|
allowed = all(not char.isascii() for char in stripped)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
logger.info(
|
||||||
|
f"Message failed hyperfocus check, attempting to delete: {message!r} "
|
||||||
|
)
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
@cmds.Component.listener()
|
||||||
|
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
|
||||||
|
profile = await self.bot.profiles.fetch_profile(payload.chatter, touch=True)
|
||||||
|
hyperfocused = await self.get_hyperfocus(profile.profileid)
|
||||||
|
|
||||||
|
# If they are, check the message content for deletion
|
||||||
|
if hyperfocused and not self.check_hyperfocus_message(payload):
|
||||||
|
# If we need to delete, run delete and send message
|
||||||
|
notify = ( #
|
||||||
|
not (last := self._last_deleted.get(profile.profileid))
|
||||||
|
or (utc_now() - last).total_seconds() > 30
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.focus_delete_message(payload)
|
||||||
|
deleted = True
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete a hyperfocus message: {payload!r}", exc_info=True
|
||||||
|
)
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
if notify:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 -----
|
||||||
|
@cmds.command(
|
||||||
|
name="hyperfocus", aliases=["hyperf", "hyper", "hypercrocus", "hyperofcus"]
|
||||||
|
)
|
||||||
|
async def hyperfocus_cmd(self, ctx, *, duration: str | None = None):
|
||||||
|
now = utc_now()
|
||||||
|
|
||||||
|
# First parse duration
|
||||||
|
if duration and duration.isdigit():
|
||||||
|
dur = int(duration) * 60
|
||||||
|
elif duration:
|
||||||
|
dur = parse_dur(duration)
|
||||||
|
if not dur:
|
||||||
|
await ctx.reply(
|
||||||
|
"USAGE: '!hyperfocus <duration>' "
|
||||||
|
"For example: '!hyperfocus 10' for 10 minutes or "
|
||||||
|
"'!hyperfocus 1h 10m' for an hour and ten minutes!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# TODO: Add to community configuration
|
||||||
|
next_hour = now.replace(minute=0, second=0, microsecond=0) + dt.timedelta(
|
||||||
|
hours=1
|
||||||
|
)
|
||||||
|
next_block = next_hour - dt.timedelta(minutes=10)
|
||||||
|
if now > next_block:
|
||||||
|
next_block += dt.timedelta(hours=1)
|
||||||
|
dur = int((next_block - now).total_seconds())
|
||||||
|
|
||||||
|
end_at = now + timedelta(seconds=dur)
|
||||||
|
|
||||||
|
# Update the row
|
||||||
|
profile = await self.bot.profiles.fetch_profile(ctx.chatter, touch=True)
|
||||||
|
pid = profile.profileid
|
||||||
|
comm = await self.bot.profiles.fetch_community(ctx.broadcaster, touch=True)
|
||||||
|
|
||||||
|
async with self.hyperfocus_lock:
|
||||||
|
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.channel.send_hyperfocus_patch(comm.communityid, focuser)
|
||||||
|
|
||||||
|
minutes = ceil(dur / 60)
|
||||||
|
await ctx.reply(
|
||||||
|
f"{ctx.chatter.name} has gone into HYPERFOCUS! "
|
||||||
|
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")
|
||||||
|
async def unfocus_cmd(self, ctx):
|
||||||
|
profile = await self.bot.profiles.fetch_profile(ctx.chatter, touch=True)
|
||||||
|
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(
|
||||||
|
"Welcome back from focus, hope it went well!"
|
||||||
|
" Remember to have a sip and stretch if you need it~"
|
||||||
|
)
|
||||||
|
|
||||||
|
@cmds.command(name="hyperfocused")
|
||||||
|
async def hyperfocused_cmd(self, ctx, user: twitchio.User | None = None):
|
||||||
|
user, own = (user, False) if user is not None else (ctx.chatter, True)
|
||||||
|
|
||||||
|
profile = await self.bot.profiles.fetch_profile(user, touch=False)
|
||||||
|
|
||||||
|
async with self.hyperfocus_lock:
|
||||||
|
if hyper := (await self.get_hyperfocus(profile.profileid)):
|
||||||
|
durstr = strfdelta(hyper.ends_at - utc_now())
|
||||||
|
await ctx.reply(
|
||||||
|
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(
|
||||||
|
"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!")
|
||||||
|
|
||||||
|
@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