diff --git a/PLAN-v3.md b/PLAN-v3.md index 82955be..524c69e 100644 --- a/PLAN-v3.md +++ b/PLAN-v3.md @@ -193,56 +193,56 @@ Do these on a test stream or with the booth open before relying on the features. ### Phase 3: Stream-day features (💡 suggestions grounded in existing code) -- [ ] **V3-P3-01 💡 Hype-train meter on the overlay and dashboard** (F-14) +- [x] **V3-P3-01 💡 Hype-train meter on the overlay and dashboard** (F-14) - *Why:* `hype_progress` events and `hype_level` status already exist; the v2 payload also carries `progress`, `goal`, `expires_at`. - *Do:* extend `_hype_extra()` to keep `progress`, `goal`, `total`, `expires_at`; broadcast a `hype` message; overlay draws a bar with level and countdown (`?hype=0` hides it); dashboard shows the same in the Twitch panel. - *Files:* `src/twitch/eventsub.py`, `src/orchestrator.py`, `src/web/static/overlay.*`, `src/web/static/app.js`, tests - *Acceptance:* a fake progress event updates the bar; an end event hides it after `overlay.alert_hold_seconds`. - *Depends on:* owner check V3-P0-02 (hype train scope) -- [ ] **V3-P3-02 💡 Stream online/offline awareness with automatic recap** +- [x] **V3-P3-02 💡 Stream online/offline awareness with automatic recap** - *Why:* EventSub `stream.online` / `stream.offline` need no scope; the recap builder already exists. - *Do:* subscribe to both (core, no scope); on online start a new memory "session" (rotate `session_id`, stamp `session_started`); on offline auto-write the recap (`data/recaps/YYYY-MM-DD-HHMM.md`), and optionally set the booth to idle. Scope recap content to the session (fixes F-09). - *Files:* `src/settings.py`, `src/twitch/auth.py`, `src/twitch/eventsub.py`, `src/orchestrator.py`, `src/recap.py`, `src/memory.py`, tests - *Acceptance:* fake online→events→offline writes one recap file with only that session's items, including cheers/redeems/polls; two sessions on one day make two files. - *Depends on:* none -- [ ] **V3-P3-03 💡 Ad-break heads-up** +- [x] **V3-P3-03 💡 Ad-break heads-up** - *Why:* `channel.ad_break.begin` (scope `channel:read:ads`) fits the existing feed/scope system. - *Do:* new optional feed `events.ad_breaks`; Pixel types (not speaks, by default) a short "ad break, back in N seconds" line and the overlay shows a countdown. - *Files:* `src/settings.py`, `src/twitch/auth.py`, `src/twitch/eventsub.py`, `src/orchestrator.py`, overlay, `config.yaml`, tests - *Acceptance:* a fake event produces one line and a countdown; missing scope shows `missing scope channel:read:ads` in the feed list. - *Depends on:* V3-P1-06 -- [ ] **V3-P3-04 💡 Real Twitch reply threads** +- [x] **V3-P3-04 💡 Real Twitch reply threads** - *Why:* `ChatLine.id` exists, but replies are plain `@name` messages. Threaded replies look cleaner in chat. - *Do:* capture the IRC `id` tag in `event_message`; when sending a viewer reply, send it as a reply (TwitchIO 2.x: raw PRIVMSG with `@reply-parent-msg-id=…`, or Helix *Send Chat Message* with `reply_parent_message_id`, which needs `user:write:chat`). Fall back to the current `@name` line. - *Files:* `src/twitch/chat.py`, `src/orchestrator.py`, tests - *Acceptance:* a fake bot records a reply-parent id on viewer replies; streamer replies and alerts stay unthreaded. - *Depends on:* none -- [ ] **V3-P3-05 💡 Clip button next to "Remember this"** +- [x] **V3-P3-05 💡 Clip button next to "Remember this"** - *Why:* Helix *Create Clip* (scope `clips:edit`) pairs with the existing marker flow. - *Do:* `POST /api/clip` creates a clip, stores the edit URL as a streamer note, and shows it in the log; add a `!pixel clip` mod verb (cooldown 60 s). - *Files:* `src/twitch/helix.py`, `src/orchestrator.py`, `src/main.py`, `src/policy.py`, dashboard, tests - *Acceptance:* fake Helix returns an id and the note is saved; a missing scope gives a clear message; the mod verb respects its cooldown. - *Depends on:* V3-P1-06 -- [ ] **V3-P3-06 💡 `!ask` question queue** +- [x] **V3-P3-06 💡 `!ask` question queue** - *Why:* `!ask` is already allowlisted and the queue panel exists. - *Do:* `!ask …` lines go into a separate "Questions" list (not auto-answered when `chat.ask_queue: true`); the dashboard shows them with **Answer** (spoken) / **Drop**; overlay can optionally show the current question. - *Files:* `src/orchestrator.py`, `src/settings.py`, `src/main.py`, `src/web/static/*`, tests - *Acceptance:* three `!ask` lines appear in order; **Answer** speaks a reply to that viewer; stale filtering doesn't drop them. - *Depends on:* V3-P2-06 (optional, easier after the split) -- [ ] **V3-P3-07 💡 Viewer notes you can edit** +- [x] **V3-P3-07 💡 Viewer notes you can edit** - *Why:* notes are auto-captured by regex (`NOTE_RE`) and shown read-only; they drive "private bookmarks" in prompts. - *Do:* add `POST /api/memory/note` (add/remove) and an editable notes list in the People panel; add a "pin" flag that survives retention pruning. - *Files:* `src/memory.py`, `src/main.py`, `src/web/static/app.js`, tests - *Acceptance:* adding a note shows up in the prompt block for that viewer; a pinned viewer survives `prune()`. - *Depends on:* none -- [ ] **V3-P3-08 💡 Shoutout-received and raid-out lines** +- [x] **V3-P3-08 💡 Shoutout-received and raid-out lines** - *Why:* EventSub `channel.shoutout.receive` / `channel.shoutout.create` (scope `moderator:read:shoutouts`) and outgoing raids (`channel.raid` with `from_broadcaster_user_id`) slot into the existing parser (outgoing raids are currently dropped at `eventsub.py:650`). - *Do:* optional feed `events.shoutouts`; Pixel thanks the channel that shouted you out; on an outgoing raid, Pixel says a goodbye line. - *Files:* `src/settings.py`, `src/twitch/auth.py`, `src/twitch/eventsub.py`, `src/orchestrator.py`, tests diff --git a/config.yaml b/config.yaml index 0081a23..2e55f20 100644 --- a/config.yaml +++ b/config.yaml @@ -104,6 +104,10 @@ chat: welcome_first_time: true # Gap between mod !pixel mute/unmute/chill/hype/stop commands. control_cooldown_seconds: 4 + # When true, !ask waits in the Questions list instead of being answered immediately. + ask_queue: false + # Gap between mod !pixel clip commands. + clip_cooldown_seconds: 60 memory: chat_context_lines: 20 @@ -125,6 +129,10 @@ events: hype_train: false polls: false predictions: false + ad_breaks: false + shoutouts: false + # On stream.offline, stop the mic and mark the booth idle. + idle_on_offline: true channel_point_rewards: hydrate: speak # A reward can also be a mapping. {user} and {input} are filled in, and input is fenced. diff --git a/src/core/chat_flow.py b/src/core/chat_flow.py index 2ca54cc..aefc6e1 100644 --- a/src/core/chat_flow.py +++ b/src/core/chat_flow.py @@ -190,6 +190,13 @@ def _control_ready(orch) -> bool: return time.monotonic() - orch._last_control_at >= wait +def _clip_ready(orch) -> bool: + wait = orch.settings.clip_cooldown_seconds + if wait <= 0: + return True + return time.monotonic() - orch._last_clip_at >= wait + + async def _type_ack(orch, text: str) -> None: if orch.twitch and orch.twitch.connected: await orch.twitch.send(text) @@ -225,6 +232,17 @@ async def _consider_chat_locked(orch) -> bool: target = orch._pick_chat_target() if target is None: return False + if orch.settings.ask_queue and (target.command or "").lower() == "!ask": + asks = [line for line in orch._chat_pending if (line.command or "").lower() == "!ask"] + chosen = asks[0] if asks else target + kept = [line for line in orch._chat_pending if line is not chosen] + orch._chat_pending.clear() + orch._chat_pending.extend(kept) + if not chosen.id: + chosen.id = orch._next_qid() + orch._questions.append(chosen) + await orch._publish_queue() + return True if pixel_control(target, orch._control_names()): orch._drop_chat_target(target) await orch._publish_queue() @@ -247,6 +265,18 @@ async def _perform_chat(orch, target: ChatLine, *, ignore_rate: bool) -> None: if not orch._can_run_mod_command(target): await orch._note_skip("not a mod", target.user) return + if verb == "clip": + if not ignore_rate and not orch._clip_ready(): + await orch._note_skip("clip cooldown", target.user) + return + try: + await orch.create_clip() + except Exception as e: + await orch.hub.broadcast({"type": "log", "data": f"Clip failed: {e}"}) + else: + orch._last_clip_at = time.monotonic() + orch._mark_reply(target.user) + return if not ignore_rate and (not orch._control_ready()): await orch._note_skip("control cooldown", target.user) return @@ -350,7 +380,8 @@ async def _decide_chat(orch, line: ChatLine, action: str) -> None: ) if orch.twitch and orch.twitch.connected: try: - await orch.twitch.send(chat_text) + parent = "" if streamer else str(getattr(line, "msg_id", "") or "") + await orch.twitch.send(chat_text, reply_parent=parent) except Exception: log.exception("chat send failed") want_speak = ( diff --git a/src/core/event_flow.py b/src/core/event_flow.py index 7afd792..8dab07e 100644 --- a/src/core/event_flow.py +++ b/src/core/event_flow.py @@ -5,6 +5,7 @@ import asyncio import logging import time +from datetime import datetime from src.llm.persona import ( mood_temperature, @@ -44,12 +45,35 @@ async def on_stream_event(orch, event: StreamEvent) -> None: } ) await orch.hub.broadcast({"type": "log", "data": f"Alert {event.kind}: {event.scene()}"}) + if event.kind in ("hype_progress", "hype_begin", "hype_end"): + await _broadcast_hype(orch, event) + if event.kind == "hype_progress": + return + if event.kind == "stream_online": + session = orch.memory.start_session() + await orch.hub.broadcast({"type": "log", "data": f"Session {session}"}) + return + if event.kind == "stream_offline": + moment = datetime.now() + _text, path = write_recap( + orch.memory, + orch.data_dir / "recaps", + when=moment, + filename=f"{moment:%Y-%m-%d-%H%M}.md", + ) + await orch.hub.broadcast({"type": "log", "data": f"Recap {path.name}"}) + if orch.settings.idle_on_offline: + await orch.set_listen(False) + return + if event.kind == "ad_break": + await orch.say_line(event.scene(), post_chat=True, speak=False) + return + if event.kind in ("shoutout_in", "shoutout_out", "raid_out"): + await orch.say_line(event.scene(), post_chat=True, speak=bool(orch.settings.events_speak)) + return if event.kind == "raid": orch._last_raider = event.user orch._last_raider_id = str(event.extra.get("from_id") or "") - if event.kind == "hype_progress": - await orch.hub.patch_status(hype_level=event.extra.get("level")) - return if event.kind == "redeem": title = str(event.extra.get("title") or "") spec = orch.settings.reward_spec(title) @@ -81,6 +105,25 @@ async def on_stream_event(orch, event: StreamEvent) -> None: orch._kick_events() +def _hype_message(orch, event: StreamEvent) -> dict: + extra = event.extra or {} + return { + "level": extra.get("level") or 1, + "progress": extra.get("progress") or 0, + "goal": extra.get("goal") or 0, + "total": extra.get("total") or 0, + "expires_at": extra.get("expires_at") or "", + "active": event.kind != "hype_end", + "hold_seconds": orch.settings.overlay_alert_hold, + } + + +async def _broadcast_hype(orch, event: StreamEvent) -> None: + payload = _hype_message(orch, event) + await orch.hub.broadcast({"type": "hype", "data": payload}) + await orch.hub.patch_status(hype=payload, hype_level=payload["level"]) + + async def _flush_follows(orch) -> None: try: await asyncio.sleep(orch.settings.follow_batch_seconds) diff --git a/src/core/lifecycle.py b/src/core/lifecycle.py index 45606ed..4836555 100644 --- a/src/core/lifecycle.py +++ b/src/core/lifecycle.py @@ -145,6 +145,8 @@ def bind( orch._reward_at: dict[str, float] = {} orch._last_caption = "" orch._queue_seq = 0 + orch._questions: deque[ChatLine] = deque(maxlen=40) + orch._last_clip_at = 0.0 orch.timings: dict[str, int | None] = { "stt_ms": None, "first_token_ms": None, @@ -647,3 +649,22 @@ async def _debug_log_window(orch, seconds: float) -> None: raise orch._debug_log_active = False orch.restore_log_level() + + +async def edit_note(orch, login: str, text: str, op: str = "add", pinned: bool | None = None) -> dict: + raw = (login or "").strip() + if raw.lower() in ("", "streamer"): + key = STREAMER_THREAD + is_streamer = True + else: + key = raw.lower() + is_streamer = False + if (op or "").strip().lower() == "remove": + orch.memory.remove_note(key, text) + elif (text or "").strip(): + orch.memory.add_note(key, text, is_streamer=is_streamer) + if pinned is not None: + orch.memory.set_pinned(key, bool(pinned)) + snap = orch.memory.snapshot() + await orch.hub.broadcast({"type": "memory", "data": snap}) + return snap diff --git a/src/core/queue.py b/src/core/queue.py index bf03db1..a2ed697 100644 --- a/src/core/queue.py +++ b/src/core/queue.py @@ -20,11 +20,19 @@ def _assign_queue_ids(orch) -> None: for line in orch._chat_pending: if not line.id: line.id = orch._next_qid() + for line in getattr(orch, "_questions", ()): + if not line.id: + line.id = orch._next_qid() for event in orch._event_pending: if not event.id: event.id = orch._next_qid() +def _retain_questions(orch, kept: list[ChatLine]) -> None: + orch._questions.clear() + orch._questions.extend(kept) + + def _retain_chat(orch, kept: list[ChatLine]) -> None: orch._chat_pending.clear() orch._chat_pending.extend(kept) @@ -41,9 +49,14 @@ def queue_snapshot(orch) -> dict[str, list]: chat = [ {"id": line.id, "user": line.user, "text": line.text[:120]} for line in list(orch._chat_pending)[:12] ] + questions = [ + {"id": line.id, "user": line.user, "text": line.text[:120]} + for line in list(getattr(orch, "_questions", ()))[:12] + ] return { "chat": chat, "alerts": [{"id": event.id, "kind": event.kind, "text": event.scene()} for event in alerts], + "questions": questions, } @@ -62,6 +75,11 @@ async def drop_queued(orch, item_id: str) -> bool: orch._retain_events([item for item in orch._event_pending if item is not event]) await orch._publish_queue() return True + for line in list(getattr(orch, "_questions", ())): + if line.id == item_id: + orch._retain_questions([item for item in orch._questions if item is not line]) + await orch._publish_queue() + return True return False @@ -82,8 +100,19 @@ async def answer_queued(orch, item_id: str) -> bool: await orch._publish_queue() await orch._react_event(event) return True + for line in list(getattr(orch, "_questions", ())): + if line.id == item_id: + orch._retain_questions([item for item in orch._questions if item is not line]) + await orch._publish_queue() + await orch._perform_chat(line, ignore_rate=True) + return True return False async def _publish_queue(orch) -> None: - await orch.hub.patch_status(queue=orch.queue_snapshot()) + snap = orch.queue_snapshot() + await orch.hub.patch_status(queue=snap) + current = (snap.get("questions") or [{}])[0] if snap.get("questions") else {} + await orch.hub.broadcast( + {"type": "question", "data": {"user": current.get("user") or "", "text": current.get("text") or ""}} + ) diff --git a/src/core/twitch_link.py b/src/core/twitch_link.py index a880711..286c8a5 100644 --- a/src/core/twitch_link.py +++ b/src/core/twitch_link.py @@ -294,3 +294,27 @@ async def mark_moment(orch, note: str) -> dict[str, str]: marker_error = str(e) log.info("stream marker skipped: %s", e) return {"note": text, "marker_error": marker_error} + + +async def create_clip(orch) -> dict[str, str]: + client = orch.eventsub + scopes = getattr(client, "scopes", None) if client is not None else None + if scopes is not None and "clips:edit" not in scopes: + raise RuntimeError("missing scope clips:edit") + if not orch.settings.twitch_client_id: + raise RuntimeError("TWITCH_CLIENT_ID is required for clips") + if client is None or not getattr(client, "broadcaster_id", ""): + raise RuntimeError("EventSub is not connected, so the broadcaster id is unknown") + result = await _live("create_clip")( + orch.settings.event_token(), + orch.settings.twitch_client_id, + client.broadcaster_id, + ) + clip_id = str(result.get("id") or "").strip() + url = str(result.get("edit_url") or "").strip() + note = url or clip_id + if note: + orch.memory.add_note(STREAMER_THREAD, note, is_streamer=True) + await orch.hub.broadcast({"type": "memory", "data": orch.memory.snapshot()}) + await orch.hub.broadcast({"type": "log", "data": f"Clip {clip_id} {url}".strip()}) + return {"id": clip_id, "edit_url": url} diff --git a/src/main.py b/src/main.py index 04e254c..63dd31a 100644 --- a/src/main.py +++ b/src/main.py @@ -97,6 +97,13 @@ class ForgetBody(BaseModel): login: str = "" +class NoteBody(BaseModel): + login: str = "" + text: str = "" + op: str = "add" + pinned: bool | None = None + + class IdBody(BaseModel): id: str = "" @@ -383,11 +390,24 @@ async def memory_forget(body: ForgetBody): app.state.orch.forget_viewer(body.login) return {"ok": True} + @app.post("/api/memory/note") + async def memory_note(body: NoteBody): + snap = await app.state.orch.edit_note(body.login, body.text, body.op, body.pinned) + return {"ok": True, "memory": snap} + @app.post("/api/marker") async def marker(body: MarkerBody): result = await app.state.orch.mark_moment(body.note) return {"ok": True, **result} + @app.post("/api/clip") + async def clip(): + try: + result = await app.state.orch.create_clip() + except Exception as e: + return JSONResponse({"ok": False, "error": str(e)}, status_code=400) + return {"ok": True, **result} + @app.post("/api/twitch/connect") async def twitch_connect(): try: diff --git a/src/memory.py b/src/memory.py index 541ce4d..2658384 100644 --- a/src/memory.py +++ b/src/memory.py @@ -239,6 +239,7 @@ class Event: text: str via: str = "chat" thread: str = "" + session_id: str = "" def line(self) -> str: stamp = time.strftime("%H:%M:%S", time.localtime(self.ts)) @@ -283,6 +284,7 @@ class Viewer: is_streamer: bool = False greeted: bool = False greeted_session: str = "" + pinned: bool = False STREAMER_THREAD = "streamer" @@ -304,7 +306,9 @@ def __init__( self.viewer_limit = viewer_limit self.thread_turns = thread_turns self.retention_days = retention_days + self._keep_session_id = session_id is not None self.session_id = session_id or secrets.token_hex(8) + self.session_started = time.time() self.events: deque[Event] = deque(maxlen=event_limit) self.viewers: dict[str, Viewer] = {} self.threads: dict[str, deque[Turn]] = {} @@ -367,6 +371,14 @@ def _ingest(self, data: dict[str, Any]) -> None: except Exception: continue self.threads[key] = dq + if not self._keep_session_id and data.get("session_id"): + self.session_id = str(data.get("session_id") or self.session_id) + started = data.get("session_started") + if started: + try: + self.session_started = float(started) + except (TypeError, ValueError): + pass def save(self) -> None: self.dirty = True @@ -380,6 +392,8 @@ def flush(self) -> None: "events": [asdict(e) for e in self.events], "viewers": {k: asdict(v) for k, v in self.viewers.items()}, "threads": {k: [asdict(t) for t in turns] for k, turns in self.threads.items()}, + "session_id": self.session_id, + "session_started": self.session_started, } raw = json.dumps(payload, ensure_ascii=False, indent=2) tmp = self.path.with_suffix(".json.tmp") @@ -421,6 +435,39 @@ def add_note(self, key: str, note: str, *, is_streamer: bool = False) -> None: self.viewers[key] = viewer self.save() + def remove_note(self, key: str, note: str) -> bool: + viewer = self.viewers.get(key) + if viewer is None: + return False + target = (note or "").strip() + kept = [item for item in viewer.notes if item != target] + if len(kept) == len(viewer.notes): + return False + viewer.notes = kept + self.viewers[key] = viewer + self.save() + return True + + def set_pinned(self, key: str, pinned: bool) -> None: + viewer = self.viewers.get(key) + if viewer is None: + viewer = Viewer(name=self.streamer_display if key == STREAMER_THREAD else key, is_streamer=key == STREAMER_THREAD) + viewer.pinned = bool(pinned) + viewer.last_ts = viewer.last_ts or time.time() + self.viewers[key] = viewer + self.save() + + def start_session(self) -> str: + """Rotate the session id. Blank event ids stay with the session that just ended.""" + previous = self.session_id + for event in self.events: + if not event.session_id: + event.session_id = previous + self.session_id = secrets.token_hex(8) + self.session_started = time.time() + self.save() + return self.session_id + def _maybe_note(self, key: str, text: str) -> None: if not NOTE_RE.search(text or ""): return @@ -454,7 +501,15 @@ def remember( thread = self.viewer_thread_key(user) else: thread = STREAMER_THREAD - ev = Event(ts=time.time(), kind=kind, user=user, text=text, via=via, thread=thread) + ev = Event( + ts=time.time(), + kind=kind, + user=user, + text=text, + via=via, + thread=thread, + session_id=self.session_id, + ) self.events.append(ev) if kind.startswith("streamer"): @@ -559,6 +614,8 @@ def prune(self, now: float | None = None) -> int: else: self.threads.pop(key, None) for key, viewer in list(self.viewers.items()): + if viewer.pinned: + continue if viewer.last_ts and viewer.last_ts < cutoff: self.viewers.pop(key, None) removed += 1 @@ -684,15 +741,17 @@ def _person_payload(self, thread_key: str, v: Viewer) -> dict[str, Any]: "last_reply": v.last_reply, "greeted": self.is_greeted(v), "notes": list(v.notes), + "pinned": bool(v.pinned), "thread": turns, } def _trim_viewers(self) -> None: - extras = {k: v for k, v in self.viewers.items() if k != STREAMER_THREAD} - if len(extras) <= self.viewer_limit: + extras = {k: v for k, v in self.viewers.items() if k != STREAMER_THREAD and not v.pinned} + pinned = sum(1 for v in self.viewers.values() if v.pinned and not v.is_streamer) + if len(extras) + pinned <= self.viewer_limit: return ranked = sorted(extras.items(), key=lambda kv: kv[1].last_ts) - drop = len(extras) - self.viewer_limit + drop = len(extras) + pinned - self.viewer_limit for key, _ in ranked[:drop]: self.viewers.pop(key, None) self.threads.pop(self.viewer_thread_key(key), None) diff --git a/src/orchestrator.py b/src/orchestrator.py index 70dfa8b..ced8d1a 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -9,7 +9,7 @@ from src.core.event_flow import EVENT_PRIORITY from src.core.twitch_link import TOKEN_REVALIDATE_SECONDS from src.settings import ROOT, update_config -from src.twitch.helix import create_marker, shoutout, user_id +from src.twitch.helix import create_clip, create_marker, shoutout, user_id log = logging.getLogger("cohost.orch") @@ -18,6 +18,7 @@ "Orchestrator", "ROOT", "TOKEN_REVALIDATE_SECONDS", + "create_clip", "create_marker", "shoutout", "update_config", @@ -297,6 +298,9 @@ def _drop_chat_target(self, target): def _control_ready(self): return chat_flow._control_ready(self) + def _clip_ready(self): + return chat_flow._clip_ready(self) + async def _type_ack(self, text): return await chat_flow._type_ack(self, text) @@ -333,6 +337,12 @@ async def shoutout(self, login): async def mark_moment(self, note): return await twitch_link.mark_moment(self, note) + async def create_clip(self): + return await twitch_link.create_clip(self) + + async def edit_note(self, login, text, op="add", pinned=None): + return await lifecycle.edit_note(self, login, text, op, pinned) + async def _note_skip(self, reason, user): return await lifecycle._note_skip(self, reason, user) @@ -348,6 +358,9 @@ def _retain_chat(self, kept): def _retain_events(self, kept): return queue._retain_events(self, kept) + def _retain_questions(self, kept): + return queue._retain_questions(self, kept) + def queue_snapshot(self): return queue.queue_snapshot(self) diff --git a/src/policy.py b/src/policy.py index c21a49a..f8040e3 100644 --- a/src/policy.py +++ b/src/policy.py @@ -3,7 +3,7 @@ from src.twitch.chat import ChatLine SPEAK_COMMANDS = {"!pixel", "!ask"} -CONTROL_VERBS = {"mute", "unmute", "chill", "hype", "stop"} +CONTROL_VERBS = {"mute", "unmute", "chill", "hype", "stop", "clip"} def control_command_names(persona_name: str = "", configured: str = "") -> set[str]: diff --git a/src/recap.py b/src/recap.py index 915ac43..daba07b 100644 --- a/src/recap.py +++ b/src/recap.py @@ -3,7 +3,7 @@ from datetime import datetime from pathlib import Path -from src.memory import STREAMER_THREAD, ConversationMemory +from src.memory import ConversationMemory def _section(title: str, lines: list[str]) -> str: @@ -11,13 +11,20 @@ def _section(title: str, lines: list[str]) -> str: return f"## {title}\n{body}" +def _session_event(memory: ConversationMemory, event) -> bool: + return event.session_id == memory.session_id + + def build_recap(memory: ConversationMemory, *, vibe: str = "", when: datetime | None = None) -> str: - """Deterministic Markdown recap. ``vibe`` is an optional one-paragraph line.""" + """Deterministic Markdown recap for the current session. ``vibe`` is optional.""" follows: list[str] = [] subs: list[str] = [] raids: list[str] = [] + cheers: list[str] = [] + redeems: list[str] = [] + polls: list[str] = [] for event in memory.events: - if event.kind != "twitch_event": + if event.kind != "twitch_event" or not _session_event(memory, event): continue text = (event.text or "").strip() if not text: @@ -29,20 +36,32 @@ def build_recap(memory: ConversationMemory, *, vibe: str = "", when: datetime | subs.append(text) elif "follow" in low: follows.append(text) + elif "cheer" in low or "bits" in low: + cheers.append(text) + elif "redeem" in low: + redeems.append(text) + elif "poll" in low or "prediction" in low: + polls.append(text) + + counts: dict[str, tuple[int, str]] = {} + for event in memory.events: + if event.kind != "viewer" or not _session_event(memory, event): + continue + name = (event.user or "").strip() + if not name: + continue + count, shown = counts.get(name.lower(), (0, name)) + counts[name.lower()] = (count + 1, shown) + chatters = sorted(counts.values(), key=lambda item: (-item[0], item[1].lower())) + chatter_lines = [f"{name} — {count} message{'s' if count != 1 else ''}" for count, name in chatters[:8]] - chatters: list[tuple[int, str]] = [] moments: list[str] = [] for key, viewer in memory.viewers.items(): - if viewer.notes: - who = viewer.name or key - for note in viewer.notes: - moments.append(f"{who}: {note}") - if key == STREAMER_THREAD or viewer.is_streamer: + if not viewer.notes: continue - if viewer.messages: - chatters.append((viewer.messages, viewer.name or key)) - chatters.sort(key=lambda item: (-item[0], item[1].lower())) - chatter_lines = [f"{name} — {count} message{'s' if count != 1 else ''}" for count, name in chatters[:8]] + who = viewer.name or key + for note in viewer.notes: + moments.append(f"{who}: {note}") stamp = (when or datetime.now()).strftime("%Y-%m-%d") parts = [ @@ -53,6 +72,12 @@ def build_recap(memory: ConversationMemory, *, vibe: str = "", when: datetime | _section("Top chatters", chatter_lines), _section("Noted moments", moments), ] + if cheers: + parts.append(_section("Cheers", cheers)) + if redeems: + parts.append(_section("Redeems", redeems)) + if polls: + parts.append(_section("Polls", polls)) paragraph = (vibe or "").strip() if paragraph: parts.append(f"## Vibe\n{paragraph}") @@ -65,10 +90,12 @@ def write_recap( *, vibe: str = "", when: datetime | None = None, + filename: str | None = None, ) -> tuple[str, Path]: moment = when or datetime.now() text = build_recap(memory, vibe=vibe, when=moment) directory.mkdir(parents=True, exist_ok=True) - path = directory / f"{moment:%Y-%m-%d}.md" + name = Path(filename).name if filename else f"{moment:%Y-%m-%d}.md" + path = directory / name path.write_text(text, encoding="utf-8") return text, path diff --git a/src/settings.py b/src/settings.py index 6fd0932..df5f5d3 100644 --- a/src/settings.py +++ b/src/settings.py @@ -196,6 +196,8 @@ def __init__(self, data: dict[str, Any]) -> None: self.chat_max_age_seconds = float(_get(data, "chat.max_age_seconds", 45)) self.welcome_first_time = bool(_get(data, "chat.welcome_first_time", False)) self.control_cooldown_seconds = float(_get(data, "chat.control_cooldown_seconds", 4)) + self.ask_queue = bool(_get(data, "chat.ask_queue", False)) + self.clip_cooldown_seconds = float(_get(data, "chat.clip_cooldown_seconds", 60)) self.ambient_chat_context = int(_get(data, "chat.ambient_context_lines", 6)) self.allow_links = bool(_get(data, "safety.allow_links", False)) @@ -216,6 +218,9 @@ def __init__(self, data: dict[str, Any]) -> None: self.hype_train_enabled = bool(_get(data, "events.hype_train", False)) self.polls_enabled = bool(_get(data, "events.polls", False)) self.predictions_enabled = bool(_get(data, "events.predictions", False)) + self.ad_breaks_enabled = bool(_get(data, "events.ad_breaks", False)) + self.shoutouts_enabled = bool(_get(data, "events.shoutouts", False)) + self.idle_on_offline = bool(_get(data, "events.idle_on_offline", True)) rewards = _get(data, "events.channel_point_rewards", {}) or {} self.channel_point_rewards = { str(key).strip().lower(): _reward_spec(value) @@ -300,6 +305,17 @@ def event_subscriptions(self) -> list[tuple[str, str]]: subs.extend([("channel.poll.begin", "1"), ("channel.poll.end", "1")]) if self.predictions_enabled: subs.extend([("channel.prediction.begin", "1"), ("channel.prediction.end", "1")]) + if self.ad_breaks_enabled: + subs.append(("channel.ad_break.begin", "1")) + if self.shoutouts_enabled: + subs.extend( + [ + ("channel.shoutout.receive", "1"), + ("channel.shoutout.create", "1"), + ("channel.raid.out", "1"), + ] + ) + subs.extend([("stream.online", "1"), ("stream.offline", "1")]) return subs diff --git a/src/twitch/auth.py b/src/twitch/auth.py index 1d39df6..32cf941 100644 --- a/src/twitch/auth.py +++ b/src/twitch/auth.py @@ -28,6 +28,12 @@ "channel.poll.end": ("channel:read:polls", "channel:manage:polls"), "channel.prediction.begin": ("channel:read:predictions", "channel:manage:predictions"), "channel.prediction.end": ("channel:read:predictions", "channel:manage:predictions"), + "channel.ad_break.begin": ("channel:read:ads",), + "channel.shoutout.receive": ("moderator:read:shoutouts",), + "channel.shoutout.create": ("moderator:read:shoutouts",), + "stream.online": (), + "stream.offline": (), + "channel.raid.out": (), } TYPE_TO_FEED: dict[str, str] = { @@ -45,6 +51,12 @@ "channel.poll.end": "polls", "channel.prediction.begin": "predictions", "channel.prediction.end": "predictions", + "channel.ad_break.begin": "ad_breaks", + "channel.shoutout.receive": "shoutouts", + "channel.shoutout.create": "shoutouts", + "channel.raid.out": "shoutouts", + "stream.online": "stream", + "stream.offline": "stream", } FEED_ORDER = ( @@ -58,11 +70,14 @@ "hype_train", "polls", "predictions", + "ad_breaks", + "shoutouts", ) ACTION_SCOPES: dict[str, tuple[str, ...]] = { "shoutout": ("moderator:manage:shoutouts",), "marker": ("channel:manage:broadcast",), + "clip": ("clips:edit",), } @@ -95,6 +110,8 @@ def remaining_ttl(expires_in: int | None, validated_at: float | None, now: float "channel.subscription.gift", "channel.subscription.message", "channel.raid", + "stream.online", + "stream.offline", ) diff --git a/src/twitch/chat.py b/src/twitch/chat.py index b484449..d4ef0e1 100644 --- a/src/twitch/chat.py +++ b/src/twitch/chat.py @@ -38,6 +38,7 @@ class ChatLine: ts: float = field(default_factory=time.monotonic) id: str = "" first_msg: bool = False + msg_id: str = "" def render(self) -> str: return f"{self.user}: {self.text}" @@ -86,6 +87,43 @@ def looks_like_auth_failure(text: str) -> bool: ) +def message_id(message: Any) -> str: + """IRC message id from TwitchIO, or the raw ``id`` tag.""" + raw = getattr(message, "id", None) + if raw: + return str(raw) + tags = getattr(message, "tags", None) + if isinstance(tags, dict): + return str(tags.get("id") or "") + return "" + + +_PARENT_RE = re.compile(r"[^A-Za-z0-9\-]") + + +def safe_reply_parent(parent: str) -> str: + return _PARENT_RE.sub("", parent or "")[:100] + + +def format_reply_privmsg(channel: str, parent_id: str, text: str) -> str: + """TwitchIO 2.x reply: a tagged PRIVMSG. Empty parent ids are the caller's job.""" + name = (channel or "").lstrip("#").strip() + parent = safe_reply_parent(parent_id) + body = (text or "").replace("\r", " ").replace("\n", " ").strip() + return f"@reply-parent-msg-id={parent} PRIVMSG #{name} :{body}" + + +def _irc_socket(bot: Any): + for attr in ("_ws", "_websocket", "websocket"): + socket = getattr(bot, attr, None) + if socket is not None and callable(getattr(socket, "send", None)): + return socket + connection = getattr(bot, "_connection", None) + if connection is not None and callable(getattr(connection, "send", None)): + return connection + return None + + def message_is_first(message: Any) -> bool: """True when Twitch marks this as the chatter's first message in the channel.""" if bool(getattr(message, "first", False)): @@ -158,7 +196,7 @@ def __init__( self._connect_error: str | None = None self.connected = False self.auth_failed = False - self._out: asyncio.Queue[str] = asyncio.Queue() + self._out: asyncio.Queue[tuple[str, str]] = asyncio.Queue() self._sender: asyncio.Task | None = None def set_allow_from(self, names: list[str]) -> None: @@ -224,6 +262,7 @@ async def event_message(inner_self, message) -> None: is_mod=is_mod, is_broadcaster=is_broadcaster, first_msg=message_is_first(message), + msg_id=message_id(message), ) parent.buffer.append(line) await parent.on_line(line) @@ -271,22 +310,23 @@ async def disconnect(self) -> None: self._sender = None await self.on_status("disconnected") - async def send(self, text: str) -> None: + async def send(self, text: str, reply_parent: str = "") -> None: text = (text or "").strip() if not text: return - await self._out.put(text[:500]) + await self._out.put((text[:500], safe_reply_parent(reply_parent))) async def _send_loop(self) -> None: while True: - text = await self._out.get() + item = await self._out.get() + text, parent = item if isinstance(item, tuple) else (str(item), "") try: - await self._send_now(text) + await self._send_now(text, parent) except Exception: log.exception("chat send failed") await asyncio.sleep(self.min_send_gap) - async def _send_now(self, text: str) -> None: + async def _send_now(self, text: str, reply_parent: str = "") -> None: if not self._bot or not text.strip(): return channel = self._bot.get_channel(self.channel) @@ -298,7 +338,15 @@ async def _send_now(self, text: str) -> None: if channel is None: log.warning("Twitch channel object missing; cannot send") return - await channel.send(text.strip()[:500]) + body = text.strip()[:500] + parent = safe_reply_parent(reply_parent) + if parent: + raw = format_reply_privmsg(getattr(channel, "name", None) or self.channel, parent, body) + socket = _irc_socket(self._bot) + if socket is not None: + await socket.send(raw) + return + await channel.send(body) def recent(self, n: int) -> list[ChatLine]: items = list(self.buffer) diff --git a/src/twitch/eventsub.py b/src/twitch/eventsub.py index 93963c6..7ace965 100644 --- a/src/twitch/eventsub.py +++ b/src/twitch/eventsub.py @@ -119,6 +119,19 @@ def scene(self) -> str: return f"A prediction just opened: {extra.get('title') or 'a prediction'}." if self.kind == "prediction_end": return _outcome_scene("Prediction", extra, "a prediction") + if self.kind == "ad_break": + seconds = extra.get("seconds") or 0 + return f"ad break, back in {seconds} seconds" + if self.kind == "shoutout_in": + return f"Thanks {self.user or 'them'} for the shoutout." + if self.kind == "shoutout_out": + return f"Just shouted out {self.user or 'them'}." + if self.kind == "raid_out": + return f"Thanks for hanging out. We're heading over to {self.user or 'another channel'}." + if self.kind == "stream_online": + return "Stream is live." + if self.kind == "stream_offline": + return "Stream is offline." return f"{self.kind}: {self.user}" @@ -516,14 +529,19 @@ async def _one(stype: str, version: str) -> str | None: log.info("Skipping %s: missing scope %s", stype, scope) return None async with sem: + real = "channel.raid" if stype == "channel.raid.out" else stype if stype == "channel.follow": condition = {"broadcaster_user_id": broadcaster_id, "moderator_user_id": moderator_id} + elif stype in ("channel.shoutout.receive", "channel.shoutout.create"): + condition = {"broadcaster_user_id": broadcaster_id, "moderator_user_id": moderator_id} elif stype == "channel.raid": condition = {"to_broadcaster_user_id": broadcaster_id} + elif stype == "channel.raid.out": + condition = {"from_broadcaster_user_id": broadcaster_id} else: condition = {"broadcaster_user_id": broadcaster_id} body = { - "type": stype, + "type": real, "version": version, "condition": condition, "transport": {"method": "websocket", "session_id": session_id}, @@ -532,7 +550,7 @@ async def _one(stype: str, version: str) -> str | None: if response.status_code in (200, 202, 409): self._note_feed(stype, "ok") return None - message = f"{stype}: {_helix_err('subscribe', response)}" + message = f"{real}: {_helix_err('subscribe', response)}" self._note_feed(stype, f"HTTP {response.status_code} {message}"[:180]) return message @@ -558,7 +576,14 @@ def _helix_err(what: str, resp: httpx.Response) -> str: def _hype_extra(event: dict[str, Any]) -> dict[str, Any]: train = str(event.get("type") or "").replace("_", " ").strip() - return {"level": event.get("level") or 1, "train": train} + return { + "level": event.get("level") or 1, + "train": train, + "progress": event.get("progress") or 0, + "goal": event.get("goal") or 0, + "total": event.get("total") or 0, + "expires_at": event.get("expires_at") or "", + } def _quiet_status(event: dict[str, Any]) -> bool: @@ -669,8 +694,17 @@ def _parse_notification(stype: str, event: dict[str, Any], channel: str = "") -> ) if stype == "channel.raid": from_login = str(event.get("from_broadcaster_user_login") or "").lower() + viewers = event.get("viewers") if channel and from_login == channel.lower(): - return None + return StreamEvent( + kind="raid_out", + user=str(event.get("to_broadcaster_user_name") or event.get("to_broadcaster_user_login") or ""), + extra={ + "viewers": viewers, + "to_id": event.get("to_broadcaster_user_id") or "", + "to_login": event.get("to_broadcaster_user_login") or "", + }, + ) return StreamEvent( kind="raid", user=user, @@ -712,4 +746,22 @@ def _parse_notification(stype: str, event: dict[str, Any], channel: str = "") -> if _quiet_status(event): return None return StreamEvent(kind="prediction_end", user="chat", extra=_prediction_extra(event)) + if stype == "channel.ad_break.begin": + return StreamEvent( + kind="ad_break", + user="chat", + extra={"seconds": _as_int(event.get("duration_seconds") or event.get("duration"))}, + ) + if stype == "channel.shoutout.receive": + who = str(event.get("from_broadcaster_user_name") or event.get("from_broadcaster_user_login") or "") + return StreamEvent(kind="shoutout_in", user=who, extra={"viewers": event.get("viewer_count") or 0}) + if stype == "channel.shoutout.create": + who = str(event.get("to_broadcaster_user_name") or event.get("to_broadcaster_user_login") or "") + return StreamEvent(kind="shoutout_out", user=who, extra={"viewers": event.get("viewer_count") or 0}) + if stype == "stream.online": + who = str(event.get("broadcaster_user_name") or event.get("broadcaster_user_login") or "") + return StreamEvent(kind="stream_online", user=who) + if stype == "stream.offline": + who = str(event.get("broadcaster_user_name") or event.get("broadcaster_user_login") or "") + return StreamEvent(kind="stream_offline", user=who) return None diff --git a/src/twitch/helix.py b/src/twitch/helix.py index 0c861ee..bdcf26e 100644 --- a/src/twitch/helix.py +++ b/src/twitch/helix.py @@ -54,6 +54,22 @@ async def shoutout( raise HelixError(f"shoutout HTTP {r.status_code}: {r.text[:200]}", r.status_code) +async def create_clip(token: str, client_id: str, broadcaster_id: str) -> dict[str, str]: + """Create a clip. Returns the Helix id and edit URL.""" + async with httpx.AsyncClient(timeout=12.0) as client: + r = await client.post( + f"{HELIX}/clips", + headers=_headers(token, client_id), + params={"broadcaster_id": broadcaster_id}, + ) + if r.status_code not in (200, 202): + raise HelixError(f"clip HTTP {r.status_code}: {r.text[:200]}", r.status_code) + data = (r.json().get("data") or [{}])[0] + if not isinstance(data, dict): + data = {} + return {"id": str(data.get("id") or ""), "edit_url": str(data.get("edit_url") or "")} + + async def create_marker(token: str, client_id: str, broadcaster_id: str, description: str) -> None: body = {"user_id": broadcaster_id, "description": (description or "cohost moment")[:140]} async with httpx.AsyncClient(timeout=12.0) as client: diff --git a/src/web/static/app.css b/src/web/static/app.css index e4fc19a..96584fc 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -121,6 +121,12 @@ button.held { background: #3a2218; border-color: #ffb38a; } .person-row { display: flex; gap: 6px; align-items: stretch; } .person-row .person { flex: 1; } button.forget { flex: none; padding: 8px 10px; font-size: 12px; } +.notes { display: flex; flex-direction: column; gap: 4px; margin: 0 0 8px; } +.note-row { display: flex; gap: 6px; align-items: center; } +.note-row span { flex: 1; font-size: 13px; } +.hype-meter { margin: 0 0 10px; } +.hype-track { height: 12px; border-radius: 99px; background: #1c2430; overflow: hidden; } +.hype-bar { height: 100%; width: 0; background: #f0c674; } .person { text-align: left; border: 1px solid var(--line); diff --git a/src/web/static/app.js b/src/web/static/app.js index d0f7b1f..e73a7c8 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -193,6 +193,12 @@ $("markerBtn").addEventListener("click", async () => { hint.textContent = data.marker_error ? `Noted. Marker: ${data.marker_error}` : "Moment noted."; }); +$("clipBtn").addEventListener("click", async () => { + const data = await post("/api/clip", {}); + if (failed(data)) return; + hint.textContent = data.edit_url ? `Clip ${data.edit_url}` : "Clip created."; +}); + personaBtn.addEventListener("click", async () => { const data = await post("/api/persona/reload", {}); if (failed(data)) return; @@ -299,9 +305,75 @@ function renderPeople(snap) { row.appendChild(forget); } peopleList.appendChild(row); + peopleList.appendChild(renderNotes(p)); } } +function noteLogin(person) { + return person.is_streamer ? "streamer" : (person.name || ""); +} + +function renderNotes(person) { + const box = document.createElement("div"); + box.className = "notes"; + for (const note of person.notes || []) { + const line = document.createElement("div"); + line.className = "note-row"; + line.appendChild(el("span", "", note)); + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "Remove"; + remove.addEventListener("click", async (ev) => { + ev.stopPropagation(); + const data = await post("/api/memory/note", { login: noteLogin(person), text: note, op: "remove" }); + if (failed(data)) return; + if (data.memory) renderMemory(data.memory); + }); + line.appendChild(remove); + box.appendChild(line); + } + const pin = document.createElement("button"); + pin.type = "button"; + pin.textContent = person.pinned ? "Unpin" : "Pin"; + pin.addEventListener("click", async (ev) => { + ev.stopPropagation(); + const data = await post("/api/memory/note", { + login: noteLogin(person), + text: "", + op: "add", + pinned: !person.pinned, + }); + if (failed(data)) return; + if (data.memory) renderMemory(data.memory); + }); + const add = document.createElement("input"); + add.type = "text"; + add.placeholder = "Add a note"; + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.textContent = "Add"; + const submitNote = async () => { + const text = add.value.trim(); + if (!text) return; + const data = await post("/api/memory/note", { login: noteLogin(person), text, op: "add" }); + if (failed(data)) return; + if (data.memory) renderMemory(data.memory); + }; + add.addEventListener("keydown", async (ev) => { + if (ev.key !== "Enter") return; + ev.preventDefault(); + await submitNote(); + }); + addBtn.addEventListener("click", (ev) => { + ev.stopPropagation(); + submitNote(); + }); + box.appendChild(pin); + box.appendChild(add); + box.appendChild(addBtn); + return box; +} + function renderThread(person) { if (!memLog) return; memLog.textContent = ""; @@ -375,7 +447,7 @@ function renderSys(health) { } } const actions = twitch.actions || {}; - for (const [id, key] of [["shoutoutBtn", "shoutout"], ["markerBtn", "marker"]]) { + for (const [id, key] of [["shoutoutBtn", "shoutout"], ["markerBtn", "marker"], ["clipBtn", "clip"]]) { const btn = $(id); const state = String(actions[key] || ""); const missing = state.startsWith("missing scope"); @@ -447,6 +519,35 @@ function renderSys(health) { if (health.duck && health.duck.note) $("duckNote").textContent = health.duck.note; } +let hypeHide = 0; + +function renderHype(data) { + const box = $("hypeMeter"); + const bar = $("hypeBar"); + const label = $("hypeLabel"); + if (!box || !data) return; + const hold = Math.max(1, Number(data.hold_seconds || 6)) * 1000; + if (data.active === false) { + clearTimeout(hypeHide); + hypeHide = setTimeout(() => { + box.hidden = true; + }, hold); + return; + } + clearTimeout(hypeHide); + box.hidden = false; + const progress = Number(data.progress) || 0; + const goal = Number(data.goal) || 0; + const pct = goal > 0 ? Math.max(0, Math.min(100, (progress / goal) * 100)) : 0; + if (bar) bar.style.width = `${pct}%`; + let left = ""; + if (data.expires_at) { + const end = Date.parse(data.expires_at); + if (!Number.isNaN(end)) left = ` · ${Math.max(0, Math.round((end - Date.now()) / 1000))}s`; + } + if (label) label.textContent = `Hype level ${data.level || 1}${left}`; +} + function queueButtons(id) { const drop = document.createElement("button"); drop.type = "button"; @@ -474,10 +575,24 @@ function renderQueue(queue) { queueLog.textContent = ""; const alerts = queue.alerts || []; const chat = queue.chat || []; - if (!alerts.length && !chat.length) { + const questions = queue.questions || []; + if (!alerts.length && !chat.length && !questions.length) { logLine(queueLog, "Queue is empty."); return; } + if (questions.length) { + queueLog.appendChild(el("p", "hint", "Questions")); + for (const line of questions) { + const row = document.createElement("div"); + row.className = "queue-row"; + const text = document.createElement("p"); + text.appendChild(el("span", "u", line.user || "?")); + text.appendChild(document.createTextNode(" " + (line.text || ""))); + row.appendChild(text); + for (const button of queueButtons(line.id)) row.appendChild(button); + queueLog.appendChild(row); + } + } for (const alert of alerts) { const row = document.createElement("div"); row.className = "queue-row"; @@ -538,6 +653,7 @@ function applyStatus(st) { $("skipLine").textContent = `Skipped: ${st.skip.reason}${st.skip.user ? " · " + st.skip.user : ""}`; } if (st.queue) renderQueue(st.queue); + if (st.hype) renderHype(st.hype); if (st.mood && document.activeElement !== $("mood")) $("mood").value = st.mood; const postVoice = $("postVoice"); if (postVoice && document.activeElement !== postVoice && typeof st.post_voice === "boolean") { @@ -750,6 +866,8 @@ function connectWs() { } else if (msg.type === "skip") { const d = msg.data || {}; $("skipLine").textContent = `Skipped: ${d.reason || ""}${d.user ? " · " + d.user : ""}`; + } else if (msg.type === "hype") { + renderHype(msg.data || {}); } else if (msg.type === "log") { logLine(eventLog, String(msg.data || ""), "warn"); } diff --git a/src/web/static/index.html b/src/web/static/index.html index 2dfb511..157667c 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -4,7 +4,7 @@ Local Twitch Cohost - +
@@ -65,6 +65,10 @@

Systems

Twitch

IRC: disconnected

EventSub: disconnected

+
@@ -73,6 +77,7 @@

Twitch

+
@@ -193,6 +198,6 @@

Log

- + diff --git a/src/web/static/overlay.css b/src/web/static/overlay.css index 2306db4..6cb5293 100644 --- a/src/web/static/overlay.css +++ b/src/web/static/overlay.css @@ -74,3 +74,24 @@ body.alerts-off .alert { display: none; } background: #3fb950; } .conn.off { background: #ff7b72; } +.hype, .ad, .question { + margin: 10px 0 0; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6); +} +.hype-track { + height: 8px; + border-radius: 99px; + background: rgba(255, 255, 255, 0.16); + overflow: hidden; +} +.hype-bar { + height: 100%; + width: 0; + background: #f0c674; +} +.hype-label, .ad, .question { + margin: 6px 0 0; + font-size: clamp(13px, 1.8vw, 18px); +} +.ad { color: #ffa198; } +.question { color: #d2a8ff; } diff --git a/src/web/static/overlay.html b/src/web/static/overlay.html index 07b7f21..dda4798 100644 --- a/src/web/static/overlay.html +++ b/src/web/static/overlay.html @@ -4,7 +4,7 @@ Cohost Overlay - +
@@ -13,7 +13,13 @@

Cohost

Waiting…

+ + +
- + diff --git a/src/web/static/overlay.js b/src/web/static/overlay.js index 0a4a4df..868fad9 100644 --- a/src/web/static/overlay.js +++ b/src/web/static/overlay.js @@ -9,9 +9,19 @@ document.body.dataset.theme = q.get("theme") || ""; document.body.dataset.position = q.get("position") || "bottom"; const hideIdle = q.get("hide") === "idle"; const alertsOff = q.get("alerts") === "0"; +const hypeOff = q.get("hype") === "0"; +const askOff = q.get("ask") === "0"; +const hypeEl = document.getElementById("hype"); +const hypeBar = document.getElementById("hypeBar"); +const hypeLabel = document.getElementById("hypeLabel"); +const adEl = document.getElementById("ad"); +const questionEl = document.getElementById("question"); let holdMs = Math.max(1, Number(q.get("hold") || 6)) * 1000; let alertTimer = 0; let typeTimer = 0; +let hypeTimer = 0; +let hypeTick = 0; +let adTimer = 0; const fontSize = Number(q.get("fontsize")); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); @@ -68,6 +78,69 @@ function setCaption(text, speaking) { }, 18); } +function hideHype() { + if (hypeEl) hypeEl.hidden = true; + clearInterval(hypeTick); +} + +function showHype(data) { + if (hypeOff || !hypeEl || !data) return; + const hold = Math.max(1, Number(data.hold_seconds || holdMs / 1000)) * 1000; + if (data.active === false) { + clearTimeout(hypeTimer); + hypeTimer = setTimeout(hideHype, hold); + return; + } + clearTimeout(hypeTimer); + hypeEl.hidden = false; + const progress = Number(data.progress) || 0; + const goal = Number(data.goal) || 0; + const pct = goal > 0 ? Math.max(0, Math.min(100, (progress / goal) * 100)) : 0; + if (hypeBar) hypeBar.style.width = `${pct}%`; + const level = data.level || 1; + const tick = () => { + let left = ""; + if (data.expires_at) { + const end = Date.parse(data.expires_at); + if (!Number.isNaN(end)) left = ` · ${Math.max(0, Math.round((end - Date.now()) / 1000))}s`; + } + if (hypeLabel) hypeLabel.textContent = `Hype level ${level}${left}`; + }; + tick(); + clearInterval(hypeTick); + hypeTick = setInterval(tick, 1000); +} + +function showAd(seconds) { + if (!adEl) return; + let left = Math.max(0, Number(seconds) || 0); + adEl.hidden = false; + const tick = () => { + adEl.textContent = `Ad break, back in ${left} seconds`; + if (left <= 0) { + clearInterval(adTimer); + return; + } + left -= 1; + }; + tick(); + clearInterval(adTimer); + adTimer = setInterval(tick, 1000); +} + +function showQuestion(data) { + if (askOff || !questionEl) return; + const text = (data && data.text) || ""; + if (!text) { + questionEl.hidden = true; + questionEl.textContent = ""; + return; + } + questionEl.hidden = false; + const user = (data && data.user) || ""; + questionEl.textContent = user ? `${user}: ${text}` : text; +} + function applyOverlayMessage(raw) { let msg; try { @@ -86,11 +159,22 @@ function applyOverlayMessage(raw) { if (st.speaking) nameEl.classList.add("speaking"); else nameEl.classList.remove("speaking"); if (typeof st.caption === "string") setCaption(st.caption, !!st.speaking); + if (st.hype) showHype(st.hype); + if (st.queue && st.queue.questions) showQuestion(st.queue.questions[0] || {}); + } else if (msg.type === "hype") { + showHype(msg.data || {}); + } else if (msg.type === "question") { + showQuestion(msg.data || {}); } else if (msg.type === "transcript" && msg.data && msg.data.role === "cohost") { setCaption(msg.data.text || "", true); } else if (msg.type === "alert") { const d = msg.data || {}; - showAlert(d.text || `${d.kind || "alert"} ${d.user || ""}`.trim(), d.kind || "alert"); + if (d.kind === "ad_break") { + const match = /(\d+)/.exec(d.text || ""); + showAd(match ? Number(match[1]) : 0); + } else { + showAlert(d.text || `${d.kind || "alert"} ${d.user || ""}`.trim(), d.kind || "alert"); + } } } diff --git a/tests/test_api.py b/tests/test_api.py index 3ff4eee..7a73ac4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -76,7 +76,7 @@ async def events_ok(): assert "oauth:" not in health.text assert client.get("/api/devices").status_code == 200 assert client.get("/api/devices?refresh=1").status_code == 200 - assert client.get("/api/queue").json() == {"chat": [], "alerts": []} + assert client.get("/api/queue").json() == {"chat": [], "alerts": [], "questions": []} assert client.get("/api/tts/voices").json()["current"] == settings.tts_voice assert client.get("/api/persona").status_code == 200 assert client.get("/api/streamer").json()["spoken_name"] diff --git a/tests/test_chat_events.py b/tests/test_chat_events.py index 635e4a8..48792b0 100644 --- a/tests/test_chat_events.py +++ b/tests/test_chat_events.py @@ -35,10 +35,19 @@ def test_event_scenes_and_outgoing_raid(): assert "gifted 5" in StreamEvent(kind="gift", user="A", extra={"total": 5}).scene() outgoing = _parse_notification( "channel.raid", - {"from_broadcaster_user_login": "mychan", "from_broadcaster_user_name": "Me", "viewers": 3}, + { + "from_broadcaster_user_login": "mychan", + "from_broadcaster_user_name": "Me", + "to_broadcaster_user_name": "Other", + "to_broadcaster_user_login": "other", + "viewers": 3, + }, channel="mychan", ) - assert outgoing is None + assert outgoing is not None + assert outgoing.kind == "raid_out" + assert outgoing.user == "Other" + assert "Other" in outgoing.scene() incoming = _parse_notification( "channel.raid", {"from_broadcaster_user_login": "nova", "from_broadcaster_user_name": "Nova", "viewers": 9}, diff --git a/tests/test_orchestrator_policy.py b/tests/test_orchestrator_policy.py index 63c6c04..ab3a3b6 100644 --- a/tests/test_orchestrator_policy.py +++ b/tests/test_orchestrator_policy.py @@ -84,9 +84,12 @@ class FakeChat: def __init__(self): self.connected = True self.sent = [] + self.replies = [] - async def send(self, text): + async def send(self, text, reply_parent=""): self.sent.append(text) + if reply_parent: + self.replies.append(reply_parent) def recent(self, n): return [] diff --git a/tests/test_phase3.py b/tests/test_phase3.py index 229a00d..1fa2f05 100644 --- a/tests/test_phase3.py +++ b/tests/test_phase3.py @@ -720,7 +720,7 @@ async def run(): ) orch.twitch = type("C", (), {"connected": True, "sent": [], "send": None, "recent": lambda self, n: []})() - async def send(text): + async def send(text, reply_parent=""): orch.twitch.sent.append(text) orch.twitch.send = send diff --git a/tests/test_start_bat.py b/tests/test_start_bat.py index 9334e99..58f2ace 100644 --- a/tests/test_start_bat.py +++ b/tests/test_start_bat.py @@ -28,6 +28,9 @@ def test_shipped_config_keeps_optional_feeds_off(): assert settings.hype_train_enabled is False assert settings.polls_enabled is False assert settings.predictions_enabled is False + assert settings.ad_breaks_enabled is False + assert settings.shoutouts_enabled is False + assert settings.ask_queue is False assert settings.allow_links is False assert "!so" in settings.mod_commands assert settings.shoutout_cooldown_seconds == 120 diff --git a/tests/test_v3_phase3.py b/tests/test_v3_phase3.py new file mode 100644 index 0000000..f7ddd1e --- /dev/null +++ b/tests/test_v3_phase3.py @@ -0,0 +1,363 @@ +import asyncio +import time +from datetime import datetime +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +from src.health import _twitch_feeds +from src.main import create_app +from src.memory import STREAMER_THREAD +from src.settings import Settings +from src.twitch.auth import missing_scope +from src.twitch.chat import ( + ChatLine, + TwitchChat, + format_reply_privmsg, + message_id, + safe_reply_parent, +) +from src.twitch.eventsub import StreamEvent, _parse_notification +from tests.test_orchestrator_policy import make_orch + + +class _Clock: + moment = datetime(2026, 10, 10, 12, 0) + + @classmethod + def now(cls, tz=None): + return cls.moment + + +class _Events: + def __init__(self, scopes): + self.scopes = scopes + self.broadcaster_id = "99" + self.moderator_id = "99" + self.feed_status = {} + + +def test_hype_meter_payload_and_end_hold(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + seen = [] + original = orch.hub.broadcast + + async def wrap(event): + seen.append(event) + await original(event) + + orch.hub.broadcast = wrap + await orch.on_stream_event( + StreamEvent( + kind="hype_progress", + user="chat", + extra={"level": 3, "progress": 40, "goal": 80, "total": 40, "expires_at": "2026-10-10T12:00:00Z"}, + ) + ) + progress = [item for item in seen if item.get("type") == "hype"][-1]["data"] + assert progress["progress"] == 40 + assert progress["goal"] == 80 + assert progress["active"] is True + assert progress["hold_seconds"] == settings.overlay_alert_hold + assert orch.hub.status["hype"]["level"] == 3 + assert not any(event.kind == "hype_progress" for event in orch._event_pending) + + await orch.on_stream_event(StreamEvent(kind="hype_end", user="chat", extra={"level": 4, "progress": 80, "goal": 80})) + ended = [item for item in seen if item.get("type") == "hype"][-1]["data"] + assert ended["active"] is False + assert ended["hold_seconds"] == settings.overlay_alert_hold + + asyncio.run(run()) + + +def test_stream_sessions_write_separate_recaps(tmp_path, settings, monkeypatch): + monkeypatch.setattr("src.core.event_flow.datetime", _Clock) + + async def run(): + orch = make_orch(tmp_path, settings) + orch.memory.remember("twitch_event", "Old", "Old just followed the channel.", via="event") + await orch.on_stream_event(StreamEvent(kind="stream_online", user="testchannel")) + orch.memory.remember("twitch_event", "A", "A cheered 50 bits.", via="event") + orch.memory.remember("twitch_event", "B", "B redeemed hydrate.", via="event") + orch.memory.remember("twitch_event", "C", "A poll just started: snacks.", via="event") + orch.mic.listen_enabled = True + _Clock.moment = datetime(2026, 10, 10, 12, 0) + await orch.on_stream_event(StreamEvent(kind="stream_offline", user="testchannel")) + first = tmp_path / "recaps" / "2026-10-10-1200.md" + assert first.exists() + text = first.read_text(encoding="utf-8") + assert "A cheered 50 bits." in text + assert "B redeemed hydrate." in text + assert "A poll just started: snacks." in text + assert "Old just followed" not in text + assert orch.mic.listen_enabled is False + + await orch.on_stream_event(StreamEvent(kind="stream_online", user="testchannel")) + orch.memory.remember("twitch_event", "D", "D just followed the channel.", via="event") + _Clock.moment = datetime(2026, 10, 10, 13, 5) + await orch.on_stream_event(StreamEvent(kind="stream_offline", user="testchannel")) + second = tmp_path / "recaps" / "2026-10-10-1305.md" + assert second.exists() + later = second.read_text(encoding="utf-8") + assert "D just followed the channel." in later + assert "A cheered 50 bits." not in later + assert "Old just followed" not in later + + asyncio.run(run()) + + +def test_ad_break_types_a_line_and_reports_missing_scope(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + event = _parse_notification( + "channel.ad_break.begin", + {"duration_seconds": 30}, + channel="testchannel", + ) + assert event is not None + assert event.kind == "ad_break" + await orch.on_stream_event(event) + assert len(orch.twitch.sent) == 1 + assert "ad break, back in 30 seconds" in orch.twitch.sent[0] + assert orch.tts.spoken == [] + assert orch.twitch.replies == [] + + asyncio.run(run()) + assert missing_scope(set(), "channel.ad_break.begin") == "channel:read:ads" + settings.ad_breaks_enabled = True + assert ("channel.ad_break.begin", "1") in settings.event_subscriptions() + bare = Settings({"server": {"host": "127.0.0.1", "port": 8080}}) + assert ("stream.online", "1") in bare.event_subscriptions() + assert ("stream.offline", "1") in bare.event_subscriptions() + assert ("channel.ad_break.begin", "1") not in bare.event_subscriptions() + probe = make_orch(tmp_path, settings) + probe.eventsub = SimpleNamespace(feed_status={"ad_breaks": "missing scope channel:read:ads"}) + assert _twitch_feeds(probe)["ad_breaks"] == "missing scope channel:read:ads" + + +def test_viewer_replies_are_threaded_and_alerts_are_not(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + await orch._decide_chat(ChatLine(user="bob", text="nice play", msg_id="abc-123"), "chat") + assert orch.twitch.replies == ["abc-123"] + await orch._decide_chat(ChatLine(user="testchannel", text="hey", msg_id="streamer-id"), "chat") + assert orch.twitch.replies == ["abc-123"] + + event = _parse_notification( + "channel.shoutout.receive", + {"from_broadcaster_user_name": "Nova"}, + channel="testchannel", + ) + settings.events_speak = False + await orch.on_stream_event(event) + raid = _parse_notification( + "channel.raid", + { + "from_broadcaster_user_login": "testchannel", + "to_broadcaster_user_name": "Other", + }, + channel="testchannel", + ) + assert raid.kind == "raid_out" + await orch.on_stream_event(raid) + assert len(orch.twitch.sent) == 4 + assert "Nova" in orch.twitch.sent[-2] + assert "Other" in orch.twitch.sent[-1] + assert orch.twitch.replies == ["abc-123"] + + asyncio.run(run()) + assert safe_reply_parent("ab c!") == "abc" + assert format_reply_privmsg("Test", "ab c!", "hi") == "@reply-parent-msg-id=abc PRIVMSG #Test :hi" + assert message_id(SimpleNamespace(tags={"id": "tag-9"})) == "tag-9" + + +def test_raw_privmsg_falls_back_without_a_socket(): + async def run(): + chat = TwitchChat("oauth:fake", "pixelbot", "testchannel", _noop, _noop) + + class Channel: + name = "testchannel" + + def __init__(self): + self.sent = [] + + async def send(self, text): + self.sent.append(text) + + class Socket: + def __init__(self): + self.raw = [] + + async def send(self, text): + self.raw.append(text) + + channel = Channel() + socket = Socket() + + class Bot: + def get_channel(self, _name): + return channel + + bot = Bot() + bot._ws = socket + chat._bot = bot + await chat._send_now("@bob hey", "parent-1") + assert socket.raw == ["@reply-parent-msg-id=parent-1 PRIVMSG #testchannel :@bob hey"] + assert channel.sent == [] + + bot._ws = None + await chat._send_now("@bob hey", "parent-1") + assert channel.sent == ["@bob hey"] + + asyncio.run(run()) + + +def test_clip_note_scope_and_cooldown(tmp_path, settings, monkeypatch): + calls = {"n": 0} + + async def fake_clip(token, client_id, broadcaster_id): + calls["n"] += 1 + assert broadcaster_id == "99" + return {"id": "abc", "edit_url": "https://clips.twitch.tv/abc/edit"} + + monkeypatch.setattr("src.orchestrator.create_clip", fake_clip) + settings.control_cooldown_seconds = 60 + settings.clip_cooldown_seconds = 60 + + async def run(): + orch = make_orch(tmp_path, settings) + orch.eventsub = _Events({"clips:edit"}) + orch._last_control_at = time.monotonic() + line = ChatLine(user="testchannel", text="!pixel clip", command="!pixel", is_broadcaster=True) + await orch._perform_chat(line, ignore_rate=False) + assert calls["n"] == 1 + notes = orch.memory.viewer(STREAMER_THREAD).notes + assert "https://clips.twitch.tv/abc/edit" in notes + await orch._perform_chat(line, ignore_rate=False) + assert calls["n"] == 1 + assert orch._last_skip["reason"] == "clip cooldown" + + orch.eventsub.scopes = set() + orch._last_clip_at = 0 + with pytest.raises(RuntimeError, match="missing scope clips:edit"): + await orch.create_clip() + + app = create_app(prepared=(settings, orch.hub, orch)) + with TestClient(app, base_url="http://127.0.0.1") as client: + token = client.get("/api/session").json()["token"] + headers = {"x-cohost-token": token, "origin": "http://127.0.0.1:8080"} + missing = client.post("/api/clip", json={}, headers=headers) + assert missing.status_code == 400 + assert missing.json()["error"] == "missing scope clips:edit" + orch.eventsub.scopes = {"clips:edit"} + created = client.post("/api/clip", json={}, headers=headers) + assert created.status_code == 200 + assert created.json()["id"] == "abc" + + asyncio.run(run()) + + +def test_ask_queue_order_answer_and_stale(tmp_path, settings): + settings.ask_queue = True + settings.chat_max_age_seconds = 1 + + async def run(): + orch = make_orch(tmp_path, settings) + now = time.monotonic() + for text in ("!ask one", "!ask two", "!ask three"): + orch._chat_pending.append(ChatLine(user="bob", text=text, command="!ask", ts=now)) + await orch._drain_chat() + assert [line.text for line in orch._questions] == ["!ask one", "!ask two", "!ask three"] + assert orch.twitch.sent == [] + assert orch.tts.spoken == [] + + stale = ChatLine(user="sam", text="hello there friend", ts=now - 30) + lingering = ChatLine(user="sam", text="!ask later", command="!ask", ts=now - 30) + orch._chat_pending.append(stale) + orch._chat_pending.append(lingering) + await orch._expire_stale_chat() + assert stale not in list(orch._chat_pending) + assert lingering in list(orch._chat_pending) + assert [line.text for line in orch._questions] == ["!ask one", "!ask two", "!ask three"] + + first = orch._questions[0].id + await orch.answer_queued(first) + assert orch.tts.spoken + assert "bob" in orch.twitch.sent[-1].lower() + assert [line.text for line in orch._questions] == ["!ask two", "!ask three"] + + asyncio.run(run()) + + +def test_editable_notes_and_pinned_viewers(tmp_path, settings): + async def run(): + orch = make_orch(tmp_path, settings) + app = create_app(prepared=(settings, orch.hub, orch)) + with TestClient(app, base_url="http://127.0.0.1") as client: + token = client.get("/api/session").json()["token"] + headers = {"x-cohost-token": token, "origin": "http://127.0.0.1:8080"} + added = client.post( + "/api/memory/note", + json={"login": "bob", "text": "likes cats", "op": "add"}, + headers=headers, + ) + assert added.status_code == 200 + assert "likes cats" in orch.memory.prompt_block("viewer:bob") + removed = client.post( + "/api/memory/note", + json={"login": "bob", "text": "likes cats", "op": "remove"}, + headers=headers, + ) + assert removed.status_code == 200 + assert "likes cats" not in orch.memory.prompt_block("viewer:bob") + client.post( + "/api/memory/note", + json={"login": "bob", "text": "", "op": "add", "pinned": True}, + headers=headers, + ) + orch.memory.viewers["bob"].last_ts = 1 + orch.memory.add_note("gone", "bye") + orch.memory.viewers["gone"].last_ts = 1 + orch.memory.viewers["gone"].pinned = False + orch.memory.prune(now=time.time()) + assert "bob" in orch.memory.viewers + assert "gone" not in orch.memory.viewers + + asyncio.run(run()) + + +def test_shoutout_feed_is_optional_and_speaks_one_line(tmp_path, settings): + bare = Settings({"server": {"host": "127.0.0.1", "port": 8080}}) + assert ("channel.raid.out", "1") not in bare.event_subscriptions() + settings.shoutouts_enabled = True + pairs = dict(settings.event_subscriptions()) + assert pairs["channel.shoutout.receive"] == "1" + assert pairs["channel.raid.out"] == "1" + assert pairs["channel.raid"] == "1" + + async def run(): + orch = make_orch(tmp_path, settings) + settings.events_speak = False + received = _parse_notification( + "channel.shoutout.receive", + {"from_broadcaster_user_name": "Nova"}, + channel="testchannel", + ) + outgoing = _parse_notification( + "channel.raid", + {"from_broadcaster_user_login": "testchannel", "to_broadcaster_user_name": "Other"}, + channel="testchannel", + ) + await orch.on_stream_event(received) + await orch.on_stream_event(outgoing) + assert len(orch.twitch.sent) == 2 + assert "Nova" in orch.twitch.sent[0] + assert "Other" in orch.twitch.sent[1] + + asyncio.run(run()) + + +async def _noop(*_args, **_kwargs): + return None