From c894adb754e92bc4c7ed87cac05eb30db70cfa5e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 1 Sep 2026 16:34:54 -0400 Subject: [PATCH] =?UTF-8?q?YouTube=E2=86=92AC=20chat=20bridge:=20televised?= =?UTF-8?q?=20guests=20in=20the=20clock=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat-manager gains a chat:service-message lane (shared-secret auth via CHAT_SERVICE_SECRET) that posts on behalf of off-AC visitors with via: "youtube" + their display name — no activity feed, no push. The REST history surface and both laklok sisters carry the field through; televised messages render behind a red banner in raster and vector alike (parity 14/14). toolchain/tv/chat-bridge.mjs polls the active broadcast's live chat (quota-mannered, backlog-drained, flood-guarded) and forwards one way — the stream itself is the return path. Claude-Session: https://claude.ai/code/session_01EaVptJ3ZhyeQ9ZZZNcp55T --- session-server/chat-manager.mjs | 59 ++++++ system/netlify/functions/chat-messages.mjs | 5 +- .../public/aesthetic.computer/disks/chat.mjs | 19 +- system/public/html/index.html | 4 + toolchain/tv/chat-bridge.mjs | 193 ++++++++++++++++++ toolchain/tv/tvchat.service | 15 ++ 6 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 toolchain/tv/chat-bridge.mjs create mode 100644 toolchain/tv/tvchat.service diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index 419d1ce1c9..1bbe74cb59 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -225,6 +225,7 @@ export class ChatManager { sub: message.user || undefined, font: message.font || "font_1", // 🔤 Include font from DB (default for old messages) }; + if (message.via) msg.via = message.via; // 📺 e.g. "youtube" — service relays if (message._id) msg.id = message._id.toString(); if (message.deleted) msg.deleted = true; instance.messages.push(msg); @@ -380,6 +381,8 @@ export class ChatManager { delete instance.authorizedConnections[id]; } else if (msg.type === "chat:message") { await this.handleChatMessage(instance, ws, id, msg); + } else if (msg.type === "chat:service-message") { + await this.handleServiceMessage(instance, ws, id, msg); } else if (msg.type === "chat:delete") { await this.handleDeleteMessage(instance, ws, id, msg); } else if (msg.type === "chat:edit") { @@ -389,6 +392,62 @@ export class ChatManager { } } + // 📺 Service relays (the TV chat bridge) post on behalf of off-AC visitors — + // e.g. YouTube live-chat viewers of the always-on broadcasts. They authorize + // with a shared secret (CHAT_SERVICE_SECRET env), not a user token, and their + // messages carry `via` (e.g. "youtube") + the visitor's display name as + // `from`, so clients can render them as televised guests rather than handles. + // No activity feed, no push notifications — guests are heard, not amplified. + async handleServiceMessage(instance, ws, id, msg) { + const secret = process.env.CHAT_SERVICE_SECRET; + if (!secret || msg.content?.secret !== secret) { + console.error(`💬 [${instance.config.name}] Service message rejected (bad secret)`); + ws.send(this.pack("unauthorized", { message: "Bad service secret." }, id)); + return; + } + + const via = String(msg.content.via || "youtube").slice(0, 16); + // Visitor names are foreign input: strip control chars and our color-code + // delimiter, clamp, and fall back rather than ever posting an empty name. + const name = + String(msg.content.name || "") + .replace(/[\u0000-\u001f\\@]/g, "") + .trim() + .slice(0, 24) || "viewer"; + let text = String(msg.content.text || "").trim(); + if (!text) return; + if (text.length > MAX_CHARS) text = text.slice(0, MAX_CHARS); + if (profanityFiltered(instance.config.name)) text = filter(text, this.filterDebug); + + let when = new Date(); + if (!this.dev) { + try { + const clockResponse = await fetch("https://aesthetic.computer/api/clock"); + if (clockResponse.ok) when = new Date(await clockResponse.text()); + } catch (err) { + console.log("💬 Clock fetch failed, using local time"); + } + } + + let insertedId; + if (!this.dev && this.db) { + try { + const collection = this.db.collection(instance.config.name); + const result = await collection.insertOne({ from: name, via, text, when, font: "font_1" }); + insertedId = result.insertedId?.toString(); + } catch (err) { + console.error(`💬 [${instance.config.name}] Service message store failed, broadcasting anyway:`, err.message); + } + } + + const out = { from: name, via, text, when, font: "font_1" }; + if (insertedId) out.id = insertedId; + instance.messages.push(out); + if (instance.messages.length > MAX_MESSAGES) instance.messages.shift(); + this.broadcast(instance, this.pack("message", out)); + console.log(`💬 [${instance.config.name}] 📺 via ${via}: ${name} (${text.length} chars)`); + } + async handleChatMessage(instance, ws, id, msg) { console.log( `💬 [${instance.config.name}] Message from ${msg.content.sub} (${msg.content.text.length} chars)` diff --git a/system/netlify/functions/chat-messages.mjs b/system/netlify/functions/chat-messages.mjs index acd919d5f9..59462f2441 100644 --- a/system/netlify/functions/chat-messages.mjs +++ b/system/netlify/functions/chat-messages.mjs @@ -155,7 +155,10 @@ export async function handler(event, context) { const messagesWithHandles = messages.map((msg) => ({ id: msg._id.toString(), - from: (msg.user && handleMap.get(msg.user)) || "anon", + // Service-relay rows (via: "youtube") carry their visitor name in + // `from` instead of a user sub — surface it rather than "anon". + from: (msg.user && handleMap.get(msg.user)) || msg.from || "anon", + via: msg.via || undefined, // Deletes are soft — the flag lives in the row while `text` keeps the // original, so mask here or the REST surface leaks deleted messages. text: msg.deleted ? "[deleted]" : msg.text, diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index bfc14fc4e8..c31e35f97b 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -1135,9 +1135,22 @@ function paint( // 🎯 Draw hover background if this message is being hovered if (hoveredMessageIndex === i) { ink(255, 255, 255, 15).box( - layout.x - 2, - layout.y - 1, - layout.width + 4, + layout.x - 2, + layout.y - 1, + layout.width + 4, + layout.height + 2 + ); + } + + // 📺 Televised guests — messages relayed in from the YouTube live-chat + // bridge (via: "youtube") get a red banner behind the whole block so + // visitors read as broadcast, not residents. Mirrored in the vector + // sister (system/public/html/index.html, `.msg.tv`). + if (message.via === "youtube") { + ink(205, 30, 30, 45).box( + layout.x - 2, + layout.y - 1, + layout.width + 4, layout.height + 2 ); } diff --git a/system/public/html/index.html b/system/public/html/index.html index 6ac7083085..44445bf2a7 100644 --- a/system/public/html/index.html +++ b/system/public/html/index.html @@ -208,6 +208,9 @@ .msg:hover { background: rgba(255,255,255,.06); } .from { color: var(--handle); font-weight: 600; } .from.me { color: #fff; } + /* 📺 Televised guests — relayed in from YouTube live chat (via: "youtube"). + Red banner mirrors the raster sister (disks/chat.mjs paint loop). */ + .msg.tv { background: rgba(205, 30, 30, 0.18); border-left: 2px solid rgb(205, 30, 30); } .body { word-break: break-word; } .time { color: var(--time); font-size: 11px; opacity: .8; margin-left: 2px; } .heart { color: var(--heart); font-size: 12px; } @@ -791,6 +794,7 @@ function decorateRow(row, msg) { row.dataset.when = new Date(msg.when).toISOString(); if (msg.edited) row.dataset.edited = "1"; row.classList.toggle("has-media", !msg.deleted && hasMediaLink(msg.text || "")); + row.classList.toggle("tv", msg.via === "youtube"); // 📺 televised guests const fromHandle = (msg.from || "").replace(/^@/, "").toLowerCase(); row.classList.toggle("own", !!auth.handle && fromHandle === auth.handle); } diff --git a/toolchain/tv/chat-bridge.mjs b/toolchain/tv/chat-bridge.mjs new file mode 100644 index 0000000000..45b5b24fd5 --- /dev/null +++ b/toolchain/tv/chat-bridge.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// chat-bridge.mjs — YouTube live chat → AC chat, one way. +// +// Watches the AC channel's active live broadcast (the laer-klokken TV +// station) and forwards each YouTube chat message into the chat-clock +// room as a "televised guest": the server's chat:service-message lane +// stamps it via:"youtube" + the visitor's display name, and both laklok +// sisters render it with the red banner. AC → YouTube needs no bridge — +// the stream itself shows the room. +// +// node chat-bridge.mjs +// +// Env file (0600): +// CHAT_SERVICE_SECRET=… shared with session-server +// YT_CLIENT_JSON=/home/jas/actv/yt/client.json +// YT_TOKEN_JSON=/home/jas/actv/yt/token.json (AC channel refresh token) +// CHAT_WSS=wss://chat-clock.aesthetic.computer +// +// Quota manners: polls every ACTIVE_POLL_MS (20s) only while a broadcast +// is live AND chat has been active in the last five minutes; otherwise +// IDLE_POLL_MS (90s). Between broadcasts it naps a minute. On 403 quota +// errors it backs off ten minutes rather than burning the project dry. + +import { readFileSync } from "node:fs"; + +const ENV_FILE = process.argv[2]; +if (!ENV_FILE) { + console.error("usage: chat-bridge.mjs "); + process.exit(1); +} +for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { + const m = line.match(/^([A-Z_]+)=(.*)$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2]; +} + +const SECRET = process.env.CHAT_SERVICE_SECRET; +const CHAT_WSS = process.env.CHAT_WSS || "wss://chat-clock.aesthetic.computer"; +const ACTIVE_POLL_MS = Number(process.env.ACTIVE_POLL_MS || 20_000); +const IDLE_POLL_MS = Number(process.env.IDLE_POLL_MS || 90_000); +const NO_BROADCAST_MS = 60_000; +const QUOTA_BACKOFF_MS = 600_000; +const MAX_TEXT = 128; // chat-manager clamps to shared MAX_CHARS anyway +if (!SECRET) { + console.error("CHAT_SERVICE_SECRET missing from env file"); + process.exit(1); +} + +const log = (...a) => console.log(`[bridge ${new Date().toISOString().slice(11, 19)}]`, ...a); + +// ── YouTube auth (same refresh dance as toolchain/youtube/yt.mjs) ──── +const client = (() => { + const raw = JSON.parse(readFileSync(process.env.YT_CLIENT_JSON, "utf8")); + const c = raw.installed || raw.web || raw; + return { id: c.client_id, secret: c.client_secret }; +})(); +const refreshToken = JSON.parse(readFileSync(process.env.YT_TOKEN_JSON, "utf8")).refresh_token; + +let accessToken = null; +let accessTokenExpiry = 0; +async function token() { + if (accessToken && Date.now() < accessTokenExpiry - 300_000) return accessToken; + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.id, + client_secret: client.secret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(`token refresh ${res.status}: ${JSON.stringify(json)}`); + accessToken = json.access_token; + accessTokenExpiry = Date.now() + (json.expires_in || 3600) * 1000; + return accessToken; +} + +async function yt(path) { + const at = await token(); + const res = await fetch(`https://www.googleapis.com/youtube/v3${path}`, { + headers: { Authorization: `Bearer ${at}` }, + }); + const json = await res.json(); + if (!res.ok) { + const reason = json.error?.errors?.[0]?.reason || res.status; + const err = new Error(`GET ${path.split("?")[0]} → ${reason}`); + err.reason = reason; + throw err; + } + return json; +} + +// ── the AC chat side: one lazy socket, reconnect on demand ─────────── +let ws = null; +function sendToChat(name, text) { + const payload = JSON.stringify({ + type: "chat:service-message", + content: { secret: SECRET, via: "youtube", name, text }, + }); + return new Promise((resolve) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(payload); + return resolve(true); + } + ws = new WebSocket(CHAT_WSS); + ws.addEventListener("open", () => { + ws.send(payload); + resolve(true); + }); + ws.addEventListener("error", (e) => { + log("chat ws error:", e.message || e.type); + resolve(false); + }); + ws.addEventListener("close", () => (ws = null)); + }); +} + +// ── main loop ──────────────────────────────────────────────────────── +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const perAuthorLast = new Map(); // displayName → last forwarded epoch ms + +async function run() { + let liveChatId = null; + let pageToken = ""; + let drained = false; // first page after (re)attach is backlog — don't forward + let lastActivity = 0; + + for (;;) { + try { + if (!liveChatId) { + const b = await yt("/liveBroadcasts?part=snippet&broadcastStatus=active&mine=true&maxResults=1"); + liveChatId = b.items?.[0]?.snippet?.liveChatId || null; + if (!liveChatId) { + await sleep(NO_BROADCAST_MS); + continue; + } + pageToken = ""; + drained = false; + log("attached to live chat", liveChatId.slice(0, 12) + "…"); + } + + const q = new URLSearchParams({ liveChatId, part: "snippet,authorDetails", maxResults: "200" }); + if (pageToken) q.set("pageToken", pageToken); + const page = await yt(`/liveChatMessages?${q}`); + pageToken = page.nextPageToken || pageToken; + + if (!drained) { + drained = true; // history swallowed; everything after this forwards + log(`backlog drained (${page.items?.length || 0} old messages skipped)`); + } else { + for (const item of page.items || []) { + if (item.snippet?.type !== "textMessageEvent") continue; + const name = item.authorDetails?.displayName || "viewer"; + let text = (item.snippet.displayMessage || "").trim(); + if (!text) continue; + if (text.length > MAX_TEXT) text = text.slice(0, MAX_TEXT); + const last = perAuthorLast.get(name) || 0; + if (Date.now() - last < 3000) continue; // per-author flood guard + perAuthorLast.set(name, Date.now()); + lastActivity = Date.now(); + const ok = await sendToChat(name, text); + log(`${ok ? "→" : "✗ dropped"} [${name}] ${text.slice(0, 60)}`); + } + } + + // The chat is gone when the broadcast ends; offlineAt also signals it. + if (page.offlineAt) { + log("chat went offline"); + liveChatId = null; + continue; + } + + const hint = Number(page.pollingIntervalMillis || 0); + const busy = Date.now() - lastActivity < 300_000; + await sleep(Math.max(hint, busy ? ACTIVE_POLL_MS : IDLE_POLL_MS)); + } catch (err) { + if (err.reason === "quotaExceeded" || err.reason === "rateLimitExceeded") { + log("quota pressure — backing off 10 min"); + await sleep(QUOTA_BACKOFF_MS); + } else if (err.reason === "liveChatEnded" || err.reason === "liveChatNotFound") { + log("live chat ended — reattaching"); + liveChatId = null; + } else { + log("error:", err.message); + await sleep(30_000); + } + } + } +} + +log(`bridge up → ${CHAT_WSS} (active ${ACTIVE_POLL_MS}ms / idle ${IDLE_POLL_MS}ms)`); +run(); diff --git a/toolchain/tv/tvchat.service b/toolchain/tv/tvchat.service new file mode 100644 index 0000000000..addd1c664c --- /dev/null +++ b/toolchain/tv/tvchat.service @@ -0,0 +1,15 @@ +# tvchat.service — the YouTube→AC chat bridge for the laer-klokken station. +# Install like the station units; needs ~/actv/chat-bridge.env (0600) with +# CHAT_SERVICE_SECRET + YT client/token paths (see chat-bridge.mjs header). +[Unit] +Description=YouTube live chat → AC chat-clock bridge +After=network-online.target + +[Service] +Type=exec +ExecStart=/usr/bin/node %h/actv/chat-bridge.mjs %h/actv/chat-bridge.env +Restart=always +RestartSec=15 + +[Install] +WantedBy=default.target -- 2.51.2