Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions PLAN-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
33 changes: 32 additions & 1 deletion src/core/chat_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
49 changes: 46 additions & 3 deletions src/core/event_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import logging
import time
from datetime import datetime

from src.llm.persona import (
mood_temperature,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/core/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
31 changes: 30 additions & 1 deletion src/core/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
}


Expand All @@ -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


Expand All @@ -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 ""}}
)
24 changes: 24 additions & 0 deletions src/core/twitch_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
20 changes: 20 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading