openvibers/openvibe.live Commits
Bind a channel to a Bot robot and expose botembed behind LIVEBOTEMBED (#22)
Changes
@@@ -176,6 +176,15 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400# chat.event.publish, chat.moderation.write and chat.cache.invalidate (audience openvibe.chat) → Chat's# ingress is deployed → set this → watch chat_bridge_outbox stop growing → then the bridge removal.#LIVE_CHAT_INGRESS=1++# Bot panel embed (server/bot/): "1" lets a channel owner bind the channel to an OpenVibe.Bot robot++# (PUT /api/streams/channel/:username/bot) and adds bot_embed { enabled, robot_id, url } to the channel++# JSON; off, the route answers 404 and the JSON is unchanged. Live never calls Bot to bind: the robot++# owner's embed_public toggle in Bot decides what the embed shows. Turn-on order: Bot deployed with++# GET /panel/:id/embed and its BOT_EMBED_ORIGINS including this site's origins → set this → bind a channel.++#LIVE_BOT_EMBED=1++# Bot's origin for the embed URL (<origin>/panel/<robot id>/embed): a bare https origin, or++# http://localhost* outside production; anything else falls back to the default with a warning.++#LIVE_BOT_URL=https://openvibe.bot# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat@@@ -113,6 +113,13 @@ Live's own principal (`live`) holds grants to call:API writes are limited per person (`server/net/actor-limits.js`, `LIVE_LIMITS_MINUTE` / `_HOUR`).++**Bot panel embed (`LIVE_BOT_EMBED`, off by default).** A channel can show its OpenVibe.Bot robot's embeddable panel (`<LIVE_BOT_URL>/panel/<robot id>/embed`). With `LIVE_BOT_EMBED=1` the channel owner (not mods, not staff) binds a robot with `PUT /api/streams/channel/:username/bot` `{ "robot_id": "rob_…" | null }` (null or `""` unbinds; stored in `channels.bot_robot_id`; limited per person as `live.bot.bind`), and `GET /api/streams/channel/:username` carries `bot_embed: { enabled: true, robot_id, url }`. Live never calls Bot to bind: the robot owner's `embed_public` toggle in Bot is the gate, so binding someone else's robot shows nothing until its owner makes it public. With the flag off the route answers 404 and the channel JSON has no `bot_embed` key (the stored id is kept). Turn it on in this order: Bot deployed with `GET /panel/:id/embed` and its `BOT_EMBED_ORIGINS` including this site's origins → set `LIVE_BOT_EMBED=1` → bind a channel.++++| Variable | Default | Meaning |++| --- | --- | --- |++| `LIVE_BOT_EMBED` | off | `1` turns on the channel ↔ Bot robot binding and `bot_embed` on the channel JSON (`server/bot/`) |++| `LIVE_BOT_URL` | `https://openvibe.bot` | Bot's origin for the embed URL; a bare https origin (http://localhost* outside production), else the default with a warning |++## Acceptance`npm test` runs every file in `test/` (`test/run.js`), each in its own process against a temp database: 150 files covering auth and revocation, security crawls (stream keys, secrets, SSRF, open redirect, private objects), the frozen tables, chat bridging, AI (every run is an OpenVibe.AI template; nothing calls a provider directly), transcripts by window, per-actor limits, account export and deletion, drill mode, N-1 compatibility fixtures, and the home page's size budgets. The browser smoke is `BASE=http://127.0.0.1:3000 npm run test:browser` (add `-- --a11y` for axe).@@@ -28,7 +28,8 @@"AI viewers, stream memories, overviews, moments, recaps, translation: every prompt an OpenVibe.AI template; VOD and clip transcripts by window on OpenVibe.AI","streamers' own AI keys stored encrypted in OpenVibe.AI; per-streamer daily AI budgets as AI quotas","channel points and Vibes; the Arena (Battle Cam); after-show recaps; the Star of OpenVibe",-- "per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network"++ "per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",++ "channel ↔ OpenVibe.Bot robot binding and the bot_embed panel URL behind LIVE_BOT_EMBED (off by default)"],-- "updated": "2026-09-28"++ "updated": "2026-10-04"}nnew file mode 100644@@@ -0,0 +1,83 @@++# Cutover: channel ↔ Bot robot binding (PR #22, `channels.bot_robot_id`)++++PR #22 (`c3e93a4`, "Bind a channel to a Bot robot and expose botembed behind LIVE_BOTEMBED") is a data change:++it adds one nullable column to Live's own SQLite database.++++- [server/db/schema.sql](../server/db/schema.sql) declares `channels.bot_robot_id TEXT` (after++ `active_control_config_id`).++- [server/db/database.js](../server/db/database.js) adds the same column idempotently on boot++ (`ALTER TABLE channels ADD COLUMN bot_robot_id TEXT` when `PRAGMA table_info(channels)` lacks it) and accepts++ `bot_robot_id` in `updateChannel`'s allowlist.++- [server/bot/embed.js](../server/bot/embed.js) and [server/bot/routes.js](../server/bot/routes.js) read and write it++ behind `LIVE_BOT_EMBED` (off by default): with the flag off the `PUT /api/streams/channel/:username/bot` route answers++ 404 and the channel JSON has no `bot_embed` key; the stored id is kept.++- [server/web/serializers.js](../server/web/serializers.js) never leaks `bot_robot_id` on public channel rows; it is only++ surfaced as `bot_embed` on `GET /api/streams/channel/:username` (see [README.md](../README.md), "Bot panel embed").++++The change is **additive and non-destructive**: a new nullable column, no rewrite, backfill or delete, no read of any++existing row, and no PostgreSQL migration (Live's `channels` table is SQLite). Every existing query, the previous release++and a rollback all ignore the extra column, so the deploy carries no data-movement step.++++## Preconditions++++- `ov access run openvibe-ovh health live` answers `ready` before you start.++- The range from the running release to this head carries no other data change; auto-finish refuses such a range,++ deploy those commits through their own runbook first.++- The flag order below: **Bot deployed first**. `GET /panel/<robot id>/embed` must exist in OpenVibe.Bot and its++ `BOT_EMBED_ORIGINS` must include this site's origins, or the embed frame stays empty.++++## Order++++1. **Back up.** `ov access run openvibe-ovh db-backup`: back up the databases now and check the Live backup's stamp++ (`<backupDir>/live/<YYYYMMDD-HHMMSS>/live.db`, `ok: true` in `<stateDir>/backups/live.jsonl`). This is the way back to++ a database without the column; the deploy's own pre-deploy backup also runs because `server/db/schema.sql` changed.++2. **Deploy with the flag off** (the default). `ov access run openvibe-ovh deploy live -- --wait-idle`: ships the range++ since the running release and restarts, gated on `/api/ready`. Boot adds the column; no data moves.++3. **Verify** (below), then, later and separately on the owner's go, **turn the flag on**: Bot deployed → set++ `LIVE_BOT_EMBED=1` in `/etc/openvibe/live.env` → `ov access run openvibe-ovh restart live` while no one is live →++ bind a channel. Step 3 is not part of this deploy.++++## How to check success (verification)++++- **Boot log.** `ov access run openvibe-ovh logs live 200` prints `[DB] Added bot_robot_id column to channels` once on++ the first boot of the new release and never again; no `no such column: bot_robot_id` and no migration `failed`.++- **Column present.** On the host, read-only:++ `sqlite3 -readonly <Live's data dir>/live.db "SELECT name FROM pragma_table_info('channels') WHERE name = 'bot_robot_id'"`++ prints `bot_robot_id`, and every other column is unchanged.++- **App still serves.** `ov access run openvibe-ovh health live` is `ready`; `GET https://openvibe.live/api/streams/channel/<a live channel>`++ returns 200 and, with the flag off, carries no `bot_embed` key (and never a `bot_robot_id`). With the flag on, the owner++ binds with `PUT /api/streams/channel/:username/bot {"robot_id":"rob_…"}` and the same GET shows++ `bot_embed: { enabled: true, robot_id, url }`.++- `test/bot-embed-binding.test.js` covers the route and the channel JSON (the pipeline's verify runs it).++++## Rollback / restore++++`ov access run openvibe-ovh rollback live` alone is enough: the previous release's code does not read, write or serialize++`bot_robot_id`, so it runs against this database unchanged, and the dropped-flag state is simply the default. Then++`ov access run openvibe-ovh health live` must be `ready`. No restore is needed — the column is additive and unused.++++If a restore is ever wanted anyway (to a database literally without the column), the owner restores the step 1 backup with++the previous release: `ov rollback live`; on the host stop Live, copy the backup's `live.db` over Live's database (as the++service user, owner and mode as before, no stale `-wal`/`-shm` beside it), start Live, `health live`. Restoring also++discards every other Live write made since step 1, so prefer the plain rollback: the extra column affects nothing that++runs.++++## Rehearsal++++The harness runs `ov rehearse OpenVibe.Live 22` on its scratch PostgreSQL (main's migrations, the repository's fixtures,++this PR's migrations, then the block below). This PR adds no migration under `migrations/` (the column is SQLite and++added inline), so the rehearsal's migrate step applies nothing on the second run; the commands confirm the change is++present and parses.++++```rehearse++# The column is SQLite-only (schema.sql + the idempotent ADD COLUMN in database.js): the scratch PostgreSQL++# holds only the analytics slice, so these check the change is declared, added idempotently and exposed.++node --check server/bot/embed.js++node --check server/bot/routes.js++grep -q 'bot_robot_id TEXT' server/db/schema.sql++grep -q 'ADD COLUMN bot_robot_id TEXT' server/db/database.js++grep -q "LIVE_BOT_EMBED === '1'" server/bot/embed.js++grep -q "bot_robot_id" server/web/serializers.js++```++++REHEARSAL: greennnew file mode 100644@@@ -0,0 +1,59 @@++/**++ * Bot panel embed (roadmap T15 R9): a channel bound to an OpenVibe.Bot robot shows that robot's++ * embeddable panel (`<bot origin>/panel/<robot id>/embed`) on its channel page.++ *++ * Everything here is inert unless LIVE_BOT_EMBED=1 (read at call time, like the other LIVE_* flags):++ * with it off the binding route answers 404 and the channel JSON carries no `bot_embed` key. Live never++ * calls Bot to bind: the robot owner's embed_public toggle in Bot decides whether the embed shows anything.++ */++'use strict';++++const DEFAULT_BOT_URL = 'https://openvibe.bot';++const ROBOT_ID_RE = /^rob_[A-Za-z0-9_-]{4,60}$/;++++let warnedFor = null;++++function enabled() {++ return process.env.LIVE_BOT_EMBED === '1';++}++++/** A bare origin (no path, query, credentials): https anywhere, http://localhost* only outside production. */++function parseOrigin(raw) {++ let u;++ try { u = new URL(raw); } catch { return null; }++ if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== '/')) return null;++ if (u.protocol === 'https:') return u.origin;++ const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');++ if (u.protocol === 'http:' && local && process.env.NODE_ENV !== 'production') return u.origin;++ return null;++}++++/** Bot's origin from LIVE_BOT_URL (default https://openvibe.bot); garbage falls back with one warning. */++function botOrigin() {++ const raw = String(process.env.LIVE_BOT_URL || '').trim().replace(/\/+$/, '');++ if (!raw) return DEFAULT_BOT_URL;++ const origin = parseOrigin(raw);++ if (origin) return origin;++ if (warnedFor !== raw) {++ warnedFor = raw;++ console.warn(`[BotEmbed] LIVE_BOT_URL is not a bare https origin (or http://localhost outside production); using ${DEFAULT_BOT_URL}`);++ }++ return DEFAULT_BOT_URL;++}++++function validRobotId(s) {++ return typeof s === 'string' && ROBOT_ID_RE.test(s);++}++++function embedUrl(robotId) {++ return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;++}++++/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url }. */++function channelEmbed(channel) {++ if (!enabled()) return undefined;++ const id = channel && validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;++ return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null };++}++++module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, DEFAULT_BOT_URL };nnew file mode 100644@@@ -0,0 +1,41 @@++/**++ * Binding a channel to an OpenVibe.Bot robot (roadmap T15 R9), mounted under /api/streams.++ *++ * PUT /api/streams/channel/:username/bot { robot_id: 'rob_…' | null } — the channel owner only++ *++ * With LIVE_BOT_EMBED off the route answers 404 as if it did not exist. Binding does not call Bot: the++ * robot owner's embed_public toggle there decides what the embed shows (server/bot/embed.js).++ */++'use strict';++++const express = require('express');++const db = require('../db/database');++const { requireAuth } = require('../auth/auth');++const embed = require('./embed');++++const router = express.Router();++++const notFound = (res) => res.status(404).json({ error: 'Not found' });++++router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next() : notFound(res)), requireAuth, (req, res) => {++ try {++ const channel = db.getChannelByUsername(req.params.username);++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ // Owner only: channel moderators and staff do not choose which robot a channel shows.++ if (Number(req.user.id) !== Number(channel.user_id)) {++ return res.status(403).json({ error: 'Only the channel owner can bind a robot' });++ }++ const raw = req.body ? req.body.robot_id : undefined;++ let robotId;++ if (raw === null || raw === '') robotId = null;++ else if (embed.validRobotId(raw)) robotId = raw;++ else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });++ db.updateChannel(channel.user_id, { bot_robot_id: robotId });++ res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId }) });++ } catch (err) {++ console.error('[BotEmbed] Bind error:', err.message);++ res.status(500).json({ error: 'Failed to bind robot' });++ }++});++++module.exports = router;@@@ -2083,6 +2083,11 @@ function initDb() {database.exec('ALTER TABLE channels ADD COLUMN control_rate_limit_ms INTEGER DEFAULT 100');console.log('[DB] Added control_rate_limit_ms column to channels');}++ // The OpenVibe.Bot robot whose panel the channel page embeds (server/bot/embed.js, LIVE_BOT_EMBED).++ if (!cols.includes('bot_robot_id')) {++ database.exec('ALTER TABLE channels ADD COLUMN bot_robot_id TEXT');++ console.log('[DB] Added bot_robot_id column to channels');++ }if (!cols.includes('video_click_rate_limit_ms')) {database.exec('ALTER TABLE channels ADD COLUMN video_click_rate_limit_ms INTEGER DEFAULT 0');console.log('[DB] Added video_click_rate_limit_ms column to channels');@@@ -4096,7 +4101,7 @@ function updateChannel(userId, fields) {const updates = [];const params = [];for (const [key, val] of Object.entries(fields)) {-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links'].includes(key)) {++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {updates.push(`${key} = ?`);params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);}@@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS channels (video_click_enabled INTEGER DEFAULT 0,video_click_rate_limit_ms INTEGER DEFAULT 0,active_control_config_id INTEGER,++ bot_robot_id TEXT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP,updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE@@@ -664,6 +664,8 @@ app.use('/data/offline', express.static(paths.data('offline'), {// ── API Routes ───────────────────────────────────────────────app.use('/api/auth', authRoutes);++// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).++app.use('/api/streams', require('./bot/routes'));app.use('/api/streams', streamRoutes);// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).const liveEvents = require('./streaming/live-events');@@@ -39,6 +39,8 @@ const ROUTES = [['live.clip.create', /^POST$/, /^\/(vods\/clips|clips)(\/[^/]+\/trim)?$/, { minute: 10, hour: 100 }],// A paste stores content on Community (anonymous pastes keep their own address limit in pastes.js).['live.paste.create', /^POST$/, /^\/pastes\/?$/, { minute: 30, hour: 600 }],++ // Binding a channel to a Bot robot (server/bot/routes.js): set once, rarely changed.++ ['live.bot.bind', /^PUT$/, /^\/streams\/channel\/[^/]+\/bot$/, { minute: 10, hour: 60 }],['live.follow', /^(POST|DELETE)$/, /^\/streams\/(channel\/[^/]+|[^/]+)\/follow$/, { minute: 60, hour: 600 }],// A new stream key invalidates the old one; nobody needs it more than a few times.['live.stream.key', /^POST$/, /^\/streams\/managed\/[^/]+\/regenerate-key$/, { minute: 5, hour: 20 }],@@@ -511,6 +511,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {delete publicChannel.stream_key;delete publicChannel.vod_recording_enabled;delete publicChannel.force_vod_recording_disabled;++ // The binding is only shown through `bot_embed` below, and only with LIVE_BOT_EMBED on.++ delete publicChannel.bot_robot_id;++ const botEmbed = require('../bot/embed').channelEmbed(channel);// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by// OpenVibe.Community and taken clips by OpenVibe.Media (media-proxy/lookups.js).@@@ -581,6 +584,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {aiClipsLimit,aiClipsOffset,aiClipsHasMore: aiClipsOffset + aiClips.length < aiClipsTotal,++ ...(botEmbed !== undefined ? { bot_embed: botEmbed } : {}),});} catch (err) {console.error('[Channels] Get error:', err.message);@@@ -38,7 +38,7 @@ function publicManagedStream(ms) {/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */function publicChannel(ch) {if (!ch || typeof ch !== 'object') return ch;-- const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled']);++ const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id']);if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');return out;}nnew file mode 100644@@@ -0,0 +1,192 @@++/**++ * Binding a channel to an OpenVibe.Bot robot (roadmap T15 R9, server/bot/).++ *++ * With LIVE_BOT_EMBED off the binding route answers 404 and the channel JSON has no `bot_embed` key.++ * With it on, only the channel owner binds (mods and others 403, anonymous 401), bad ids are 400, null++ * unbinds, and the channel GET carries { enabled, robot_id, url } built from LIVE_BOT_URL.++ * The real routers run on a temp database; only sign-in is stubbed (`x-test-user` header).++ *++ * node test/bot-embed-binding.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-bot-embed-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++delete process.env.LIVE_BOT_EMBED;++delete process.env.LIVE_BOT_URL;++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++const warnings = [];++console.warn = (...a) => { warnings.push(a.join(' ')); };++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, role) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)`)++ .run(id, username, username, `${username}@x`, role);++addUser(1, 'rover', 'streamer');++addUser(2, 'modguy', 'user');++addUser(3, 'stranger', 'streamer');++db.ensureChannel(1);++const chan = db.getChannelByUserId(1);++++// modguy is a channel moderator of rover (read from Chat through the moderation client).++const moderation = require('../server/chat/moderation-client');++moderation.getChannelModeration = async (channelId) => ({ settings: {}, moderator_ids: channelId === chan.id ? [2] : [] });++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/streams', require('../server/bot/routes'));++app.use('/api/streams', require('../server/streaming/routes'));++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }++}++++(async () => {++ const server = http.createServer(app).listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const call = (method, p, { user, body } = {}) => new Promise((resolve, reject) => {++ const data = body === undefined ? null : JSON.stringify(body);++ const headers = {};++ if (user) headers['x-test-user'] = String(user);++ if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }++ const req = http.request({ host: '127.0.0.1', port: server.address().port, method, path: p, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, body: json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++ const channelGet = () => call('GET', '/api/streams/channel/rover?pollOnly=1');++ const bind = (robot_id, user = 1) => call('PUT', '/api/streams/channel/rover/bot', { user, body: { robot_id } });++++ await check('flag off: PUT answers 404 as if the route did not exist, the column stays unset', async () => {++ const r = await bind('rob_sim123');++ assert.strictEqual(r.status, 404);++ assert.deepStrictEqual(r.body, { error: 'Not found' });++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);++ });++++ await check('flag off: the channel GET has no bot_embed key and no bot_robot_id even when one is stored', async () => {++ raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_stored1', chan.id);++ const r = await channelGet();++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.ok(!('bot_embed' in r.body), 'no bot_embed key');++ assert.ok(!('bot_robot_id' in r.body.channel), 'no raw column on the public channel');++ assert.ok(!r.text.includes('rob_stored1'));++ raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(chan.id);++ });++++ process.env.LIVE_BOT_EMBED = '1';++++ await check('flag on, unbound: bot_embed is enabled with no robot', async () => {++ const r = await channelGet();++ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });++ });++++ await check('flag on: anonymous 401, non-owner 403, channel mod 403', async () => {++ assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { body: { robot_id: 'rob_sim123' } })).status, 401);++ assert.strictEqual((await bind('rob_sim123', 3)).status, 403);++ assert.strictEqual((await bind('rob_sim123', 2)).status, 403);++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);++ });++++ await check('flag on: bad ids are 400', async () => {++ const bad = ['sim123', 'rob_', 'rob_ab', 'rob_has space', 'rob_../../x', 'rob_' + 'a'.repeat(61), 42, {}, ['rob_sim123']];++ const statuses = await Promise.all(bad.map((b) => bind(b).then((r) => r.status)));++ assert.deepStrictEqual(statuses, bad.map(() => 400));++ assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: {} })).status, 400, 'missing robot_id');++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);++ });++++ await check('flag on: unknown channel is 404', async () => {++ const r = await call('PUT', '/api/streams/channel/nobody/bot', { user: 1, body: { robot_id: 'rob_sim123' } });++ assert.strictEqual(r.status, 404);++ });++++ await check('flag on: the owner binds rob_sim123 and the channel GET returns the embed', async () => {++ const r = await bind('rob_sim123');++ assert.strictEqual(r.status, 200, r.text);++ const g = await channelGet();++ assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' });++ assert.ok(!('bot_robot_id' in g.body.channel));++ });++++ await check('a custom LIVE_BOT_URL changes the origin', async () => {++ process.env.LIVE_BOT_URL = 'https://bot.example.test/';++ assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://bot.example.test/panel/rob_sim123/embed');++ process.env.LIVE_BOT_URL = 'http://localhost:3020';++ assert.strictEqual((await channelGet()).body.bot_embed.url, 'http://localhost:3020/panel/rob_sim123/embed');++ });++++ await check('a garbage LIVE_BOT_URL falls back to the default with a warning', async () => {++ const embed = require('../server/bot/embed');++ const garbage = ['not a url', 'http://bot.example.test', 'https://bot.example.test/path', 'https://u:[email protected]', 'javascript:alert(1)'];++ const results = garbage.map((bad) => {++ warnings.length = 0;++ process.env.LIVE_BOT_URL = bad;++ return [bad, embed.botOrigin(), warnings.some((w) => w.includes('LIVE_BOT_URL'))];++ });++ assert.deepStrictEqual(results, garbage.map((bad) => [bad, 'https://openvibe.bot', true]));++ process.env.LIVE_BOT_URL = 'http://localhost:3020';++ process.env.NODE_ENV = 'production';++ assert.strictEqual(embed.botOrigin(), 'https://openvibe.bot', 'no http localhost in production');++ process.env.NODE_ENV = 'test';++ process.env.LIVE_BOT_URL = 'not a url';++ assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://openvibe.bot/panel/rob_sim123/embed');++ delete process.env.LIVE_BOT_URL;++ });++++ await check('null and empty string unbind; url is null', async () => {++ const unbound = { enabled: true, robot_id: null, url: null };++ await bind('rob_sim123');++ const a = await bind(null);++ assert.strictEqual(a.status, 200, a.text);++ assert.deepStrictEqual(a.body.bot_embed, unbound);++ assert.deepStrictEqual((await channelGet()).body.bot_embed, unbound);++ await bind('rob_sim123');++ const b = await bind('');++ assert.strictEqual(b.status, 200, b.text);++ assert.deepStrictEqual((await channelGet()).body.bot_embed, unbound);++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);++ });++++ await check('turning the flag off again hides the binding', async () => {++ await bind('rob_sim123');++ delete process.env.LIVE_BOT_EMBED;++ assert.ok(!('bot_embed' in (await channelGet()).body));++ assert.strictEqual((await bind(null)).status, 404);++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, 'rob_sim123', 'binding kept for when the flag returns');++ });++++ server.close();++ db.getDb().close();++ [tmp, `${tmp}-wal`, `${tmp}-shm`].forEach((f) => { try { fs.unlinkSync(f); } catch { /* */ } });++ if (failures) { console.log(`bot-embed-binding: ${failures} failed`); process.exit(1); }++ console.log('bot-embed-binding: all passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -176,6 +176,15 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -176,6 +176,15 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# chat.event.publish, chat.moderation.write and chat.cache.invalidate (audience openvibe.chat) → Chat's
# chat.event.publish, chat.moderation.write and chat.cache.invalidate (audience openvibe.chat) → Chat's
# ingress is deployed → set this → watch chat_bridge_outbox stop growing → then the bridge removal.
# ingress is deployed → set this → watch chat_bridge_outbox stop growing → then the bridge removal.
#LIVE_CHAT_INGRESS=1
#LIVE_CHAT_INGRESS=1
+# Bot panel embed (server/bot/): "1" lets a channel owner bind the channel to an OpenVibe.Bot robot
+# (PUT /api/streams/channel/:username/bot) and adds bot_embed { enabled, robot_id, url } to the channel
+# JSON; off, the route answers 404 and the JSON is unchanged. Live never calls Bot to bind: the robot
+# owner's embed_public toggle in Bot decides what the embed shows. Turn-on order: Bot deployed with
+# GET /panel/:id/embed and its BOT_EMBED_ORIGINS including this site's origins → set this → bind a channel.
+#LIVE_BOT_EMBED=1
+# Bot's origin for the embed URL (<origin>/panel/<robot id>/embed): a bare https origin, or
+# http://localhost* outside production; anything else falls back to the default with a warning.
+#LIVE_BOT_URL=https://openvibe.bot
# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;
# Voice/video calls run in OpenVibe.Chat when this is "chat" (WS-I task 1;
# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end
# server/streaming/calls-authority.js): go-live, stream end, WHIP teardown and the admin force-end
# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat
# create/remove stream voice channels in Chat (POST/DELETE /internal/calls/stream-channel, the chat
@@ -113,6 +113,13 @@ Live's own principal (`live`) holds grants to call:
@@ -113,6 +113,13 @@ Live's own principal (`live`) holds grants to call:
API writes are limited per person (`server/net/actor-limits.js`, `LIVE_LIMITS_MINUTE` / `_HOUR`).
API writes are limited per person (`server/net/actor-limits.js`, `LIVE_LIMITS_MINUTE` / `_HOUR`).
+**Bot panel embed (`LIVE_BOT_EMBED`, off by default).** A channel can show its OpenVibe.Bot robot's embeddable panel (`<LIVE_BOT_URL>/panel/<robot id>/embed`). With `LIVE_BOT_EMBED=1` the channel owner (not mods, not staff) binds a robot with `PUT /api/streams/channel/:username/bot` `{ "robot_id": "rob_…" | null }` (null or `""` unbinds; stored in `channels.bot_robot_id`; limited per person as `live.bot.bind`), and `GET /api/streams/channel/:username` carries `bot_embed: { enabled: true, robot_id, url }`. Live never calls Bot to bind: the robot owner's `embed_public` toggle in Bot is the gate, so binding someone else's robot shows nothing until its owner makes it public. With the flag off the route answers 404 and the channel JSON has no `bot_embed` key (the stored id is kept). Turn it on in this order: Bot deployed with `GET /panel/:id/embed` and its `BOT_EMBED_ORIGINS` including this site's origins → set `LIVE_BOT_EMBED=1` → bind a channel.
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `LIVE_BOT_EMBED` | off | `1` turns on the channel ↔ Bot robot binding and `bot_embed` on the channel JSON (`server/bot/`) |
+| `LIVE_BOT_URL` | `https://openvibe.bot` | Bot's origin for the embed URL; a bare https origin (http://localhost* outside production), else the default with a warning |
+
## Acceptance
## Acceptance
`npm test` runs every file in `test/` (`test/run.js`), each in its own process against a temp database: 150 files covering auth and revocation, security crawls (stream keys, secrets, SSRF, open redirect, private objects), the frozen tables, chat bridging, AI (every run is an OpenVibe.AI template; nothing calls a provider directly), transcripts by window, per-actor limits, account export and deletion, drill mode, N-1 compatibility fixtures, and the home page's size budgets. The browser smoke is `BASE=http://127.0.0.1:3000 npm run test:browser` (add `-- --a11y` for axe).
`npm test` runs every file in `test/` (`test/run.js`), each in its own process against a temp database: 150 files covering auth and revocation, security crawls (stream keys, secrets, SSRF, open redirect, private objects), the frozen tables, chat bridging, AI (every run is an OpenVibe.AI template; nothing calls a provider directly), transcripts by window, per-actor limits, account export and deletion, drill mode, N-1 compatibility fixtures, and the home page's size budgets. The browser smoke is `BASE=http://127.0.0.1:3000 npm run test:browser` (add `-- --a11y` for axe).
@@ -28,7 +28,8 @@
@@ -28,7 +28,8 @@
"AI viewers, stream memories, overviews, moments, recaps, translation: every prompt an OpenVibe.AI template; VOD and clip transcripts by window on OpenVibe.AI",
"AI viewers, stream memories, overviews, moments, recaps, translation: every prompt an OpenVibe.AI template; VOD and clip transcripts by window on OpenVibe.AI",
"streamers' own AI keys stored encrypted in OpenVibe.AI; per-streamer daily AI budgets as AI quotas",
"streamers' own AI keys stored encrypted in OpenVibe.AI; per-streamer daily AI budgets as AI quotas",
"channel points and Vibes; the Arena (Battle Cam); after-show recaps; the Star of OpenVibe",
"channel points and Vibes; the Arena (Battle Cam); after-show recaps; the Star of OpenVibe",
- "per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network"
+ "per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
+ "channel ↔ OpenVibe.Bot robot binding and the bot_embed panel URL behind LIVE_BOT_EMBED (off by default)"
],
],
- "updated": "2026-09-28"
+ "updated": "2026-10-04"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+# Cutover: channel ↔ Bot robot binding (PR #22, `channels.bot_robot_id`)
+
+PR #22 (`c3e93a4`, "Bind a channel to a Bot robot and expose botembed behind LIVE_BOTEMBED") is a data change:
+it adds one nullable column to Live's own SQLite database.
+
+- [server/db/schema.sql](../server/db/schema.sql) declares `channels.bot_robot_id TEXT` (after
+ `active_control_config_id`).
+- [server/db/database.js](../server/db/database.js) adds the same column idempotently on boot
+ (`ALTER TABLE channels ADD COLUMN bot_robot_id TEXT` when `PRAGMA table_info(channels)` lacks it) and accepts
+ `bot_robot_id` in `updateChannel`'s allowlist.
+- [server/bot/embed.js](../server/bot/embed.js) and [server/bot/routes.js](../server/bot/routes.js) read and write it
+ behind `LIVE_BOT_EMBED` (off by default): with the flag off the `PUT /api/streams/channel/:username/bot` route answers
+ 404 and the channel JSON has no `bot_embed` key; the stored id is kept.
+- [server/web/serializers.js](../server/web/serializers.js) never leaks `bot_robot_id` on public channel rows; it is only
+ surfaced as `bot_embed` on `GET /api/streams/channel/:username` (see [README.md](../README.md), "Bot panel embed").
+
+The change is **additive and non-destructive**: a new nullable column, no rewrite, backfill or delete, no read of any
+existing row, and no PostgreSQL migration (Live's `channels` table is SQLite). Every existing query, the previous release
+and a rollback all ignore the extra column, so the deploy carries no data-movement step.
+
+## Preconditions
+
+- `ov access run openvibe-ovh health live` answers `ready` before you start.
+- The range from the running release to this head carries no other data change; auto-finish refuses such a range,
+ deploy those commits through their own runbook first.
+- The flag order below: **Bot deployed first**. `GET /panel/<robot id>/embed` must exist in OpenVibe.Bot and its
+ `BOT_EMBED_ORIGINS` must include this site's origins, or the embed frame stays empty.
+
+## Order
+
+1. **Back up.** `ov access run openvibe-ovh db-backup`: back up the databases now and check the Live backup's stamp
+ (`<backupDir>/live/<YYYYMMDD-HHMMSS>/live.db`, `ok: true` in `<stateDir>/backups/live.jsonl`). This is the way back to
+ a database without the column; the deploy's own pre-deploy backup also runs because `server/db/schema.sql` changed.
+2. **Deploy with the flag off** (the default). `ov access run openvibe-ovh deploy live -- --wait-idle`: ships the range
+ since the running release and restarts, gated on `/api/ready`. Boot adds the column; no data moves.
+3. **Verify** (below), then, later and separately on the owner's go, **turn the flag on**: Bot deployed → set
+ `LIVE_BOT_EMBED=1` in `/etc/openvibe/live.env` → `ov access run openvibe-ovh restart live` while no one is live →
+ bind a channel. Step 3 is not part of this deploy.
+
+## How to check success (verification)
+
+- **Boot log.** `ov access run openvibe-ovh logs live 200` prints `[DB] Added bot_robot_id column to channels` once on
+ the first boot of the new release and never again; no `no such column: bot_robot_id` and no migration `failed`.
+- **Column present.** On the host, read-only:
+ `sqlite3 -readonly <Live's data dir>/live.db "SELECT name FROM pragma_table_info('channels') WHERE name = 'bot_robot_id'"`
+ prints `bot_robot_id`, and every other column is unchanged.
+- **App still serves.** `ov access run openvibe-ovh health live` is `ready`; `GET https://openvibe.live/api/streams/channel/<a live channel>`
+ returns 200 and, with the flag off, carries no `bot_embed` key (and never a `bot_robot_id`). With the flag on, the owner
+ binds with `PUT /api/streams/channel/:username/bot {"robot_id":"rob_…"}` and the same GET shows
+ `bot_embed: { enabled: true, robot_id, url }`.
+- `test/bot-embed-binding.test.js` covers the route and the channel JSON (the pipeline's verify runs it).
+
+## Rollback / restore
+
+`ov access run openvibe-ovh rollback live` alone is enough: the previous release's code does not read, write or serialize
+`bot_robot_id`, so it runs against this database unchanged, and the dropped-flag state is simply the default. Then
+`ov access run openvibe-ovh health live` must be `ready`. No restore is needed — the column is additive and unused.
+
+If a restore is ever wanted anyway (to a database literally without the column), the owner restores the step 1 backup with
+the previous release: `ov rollback live`; on the host stop Live, copy the backup's `live.db` over Live's database (as the
+service user, owner and mode as before, no stale `-wal`/`-shm` beside it), start Live, `health live`. Restoring also
+discards every other Live write made since step 1, so prefer the plain rollback: the extra column affects nothing that
+runs.
+
+## Rehearsal
+
+The harness runs `ov rehearse OpenVibe.Live 22` on its scratch PostgreSQL (main's migrations, the repository's fixtures,
+this PR's migrations, then the block below). This PR adds no migration under `migrations/` (the column is SQLite and
+added inline), so the rehearsal's migrate step applies nothing on the second run; the commands confirm the change is
+present and parses.
+
+```rehearse
+# The column is SQLite-only (schema.sql + the idempotent ADD COLUMN in database.js): the scratch PostgreSQL
+# holds only the analytics slice, so these check the change is declared, added idempotently and exposed.
+node --check server/bot/embed.js
+node --check server/bot/routes.js
+grep -q 'bot_robot_id TEXT' server/db/schema.sql
+grep -q 'ADD COLUMN bot_robot_id TEXT' server/db/database.js
+grep -q "LIVE_BOT_EMBED === '1'" server/bot/embed.js
+grep -q "bot_robot_id" server/web/serializers.js
+```
+
+REHEARSAL: green
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+/**
+ * Bot panel embed (roadmap T15 R9): a channel bound to an OpenVibe.Bot robot shows that robot's
+ * embeddable panel (`<bot origin>/panel/<robot id>/embed`) on its channel page.
+ *
+ * Everything here is inert unless LIVE_BOT_EMBED=1 (read at call time, like the other LIVE_* flags):
+ * with it off the binding route answers 404 and the channel JSON carries no `bot_embed` key. Live never
+ * calls Bot to bind: the robot owner's embed_public toggle in Bot decides whether the embed shows anything.
+ */
+'use strict';
+
+const DEFAULT_BOT_URL = 'https://openvibe.bot';
+const ROBOT_ID_RE = /^rob_[A-Za-z0-9_-]{4,60}$/;
+
+let warnedFor = null;
+
+function enabled() {
+ return process.env.LIVE_BOT_EMBED === '1';
+}
+
+/** A bare origin (no path, query, credentials): https anywhere, http://localhost* only outside production. */
+function parseOrigin(raw) {
+ let u;
+ try { u = new URL(raw); } catch { return null; }
+ if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== '/')) return null;
+ if (u.protocol === 'https:') return u.origin;
+ const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');
+ if (u.protocol === 'http:' && local && process.env.NODE_ENV !== 'production') return u.origin;
+ return null;
+}
+
+/** Bot's origin from LIVE_BOT_URL (default https://openvibe.bot); garbage falls back with one warning. */
+function botOrigin() {
+ const raw = String(process.env.LIVE_BOT_URL || '').trim().replace(/\/+$/, '');
+ if (!raw) return DEFAULT_BOT_URL;
+ const origin = parseOrigin(raw);
+ if (origin) return origin;
+ if (warnedFor !== raw) {
+ warnedFor = raw;
+ console.warn(`[BotEmbed] LIVE_BOT_URL is not a bare https origin (or http://localhost outside production); using ${DEFAULT_BOT_URL}`);
+ }
+ return DEFAULT_BOT_URL;
+}
+
+function validRobotId(s) {
+ return typeof s === 'string' && ROBOT_ID_RE.test(s);
+}
+
+function embedUrl(robotId) {
+ return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
+}
+
+/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url }. */
+function channelEmbed(channel) {
+ if (!enabled()) return undefined;
+ const id = channel && validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;
+ return { enabled: true, robot_id: id, url: id ? embedUrl(id) : null };
+}
+
+module.exports = { enabled, botOrigin, validRobotId, embedUrl, channelEmbed, DEFAULT_BOT_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+/**
+ * Binding a channel to an OpenVibe.Bot robot (roadmap T15 R9), mounted under /api/streams.
+ *
+ * PUT /api/streams/channel/:username/bot { robot_id: 'rob_…' | null } — the channel owner only
+ *
+ * With LIVE_BOT_EMBED off the route answers 404 as if it did not exist. Binding does not call Bot: the
+ * robot owner's embed_public toggle there decides what the embed shows (server/bot/embed.js).
+ */
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const { requireAuth } = require('../auth/auth');
+const embed = require('./embed');
+
+const router = express.Router();
+
+const notFound = (res) => res.status(404).json({ error: 'Not found' });
+
+router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next() : notFound(res)), requireAuth, (req, res) => {
+ try {
+ const channel = db.getChannelByUsername(req.params.username);
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ // Owner only: channel moderators and staff do not choose which robot a channel shows.
+ if (Number(req.user.id) !== Number(channel.user_id)) {
+ return res.status(403).json({ error: 'Only the channel owner can bind a robot' });
+ }
+ const raw = req.body ? req.body.robot_id : undefined;
+ let robotId;
+ if (raw === null || raw === '') robotId = null;
+ else if (embed.validRobotId(raw)) robotId = raw;
+ else return res.status(400).json({ error: 'robot_id must be a Bot robot id (rob_…) or null' });
+ db.updateChannel(channel.user_id, { bot_robot_id: robotId });
+ res.json({ ok: true, bot_embed: embed.channelEmbed({ bot_robot_id: robotId }) });
+ } catch (err) {
+ console.error('[BotEmbed] Bind error:', err.message);
+ res.status(500).json({ error: 'Failed to bind robot' });
+ }
+});
+
+module.exports = router;
@@ -2083,6 +2083,11 @@ function initDb() {
@@ -2083,6 +2083,11 @@ function initDb() {
database.exec('ALTER TABLE channels ADD COLUMN control_rate_limit_ms INTEGER DEFAULT 100');
database.exec('ALTER TABLE channels ADD COLUMN control_rate_limit_ms INTEGER DEFAULT 100');
console.log('[DB] Added control_rate_limit_ms column to channels');
console.log('[DB] Added control_rate_limit_ms column to channels');
}
}
+ // The OpenVibe.Bot robot whose panel the channel page embeds (server/bot/embed.js, LIVE_BOT_EMBED).
+ if (!cols.includes('bot_robot_id')) {
+ database.exec('ALTER TABLE channels ADD COLUMN bot_robot_id TEXT');
+ console.log('[DB] Added bot_robot_id column to channels');
+ }
if (!cols.includes('video_click_rate_limit_ms')) {
if (!cols.includes('video_click_rate_limit_ms')) {
database.exec('ALTER TABLE channels ADD COLUMN video_click_rate_limit_ms INTEGER DEFAULT 0');
database.exec('ALTER TABLE channels ADD COLUMN video_click_rate_limit_ms INTEGER DEFAULT 0');
console.log('[DB] Added video_click_rate_limit_ms column to channels');
console.log('[DB] Added video_click_rate_limit_ms column to channels');
@@ -4096,7 +4101,7 @@ function updateChannel(userId, fields) {
@@ -4096,7 +4101,7 @@ function updateChannel(userId, fields) {
const updates = [];
const updates = [];
const params = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
for (const [key, val] of Object.entries(fields)) {
- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links'].includes(key)) {
+ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {
updates.push(`${key} = ?`);
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
}
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS channels (
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS channels (
video_click_enabled INTEGER DEFAULT 0,
video_click_enabled INTEGER DEFAULT 0,
video_click_rate_limit_ms INTEGER DEFAULT 0,
video_click_rate_limit_ms INTEGER DEFAULT 0,
active_control_config_id INTEGER,
active_control_config_id INTEGER,
+ bot_robot_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -664,6 +664,8 @@ app.use('/data/offline', express.static(paths.data('offline'), {
@@ -664,6 +664,8 @@ app.use('/data/offline', express.static(paths.data('offline'), {
// ── API Routes ───────────────────────────────────────────────
// ── API Routes ───────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/auth', authRoutes);
+// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
+app.use('/api/streams', require('./bot/routes'));
app.use('/api/streams', streamRoutes);
app.use('/api/streams', streamRoutes);
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
const liveEvents = require('./streaming/live-events');
const liveEvents = require('./streaming/live-events');
@@ -39,6 +39,8 @@ const ROUTES = [
@@ -39,6 +39,8 @@ const ROUTES = [
['live.clip.create', /^POST$/, /^\/(vods\/clips|clips)(\/[^/]+\/trim)?$/, { minute: 10, hour: 100 }],
['live.clip.create', /^POST$/, /^\/(vods\/clips|clips)(\/[^/]+\/trim)?$/, { minute: 10, hour: 100 }],
// A paste stores content on Community (anonymous pastes keep their own address limit in pastes.js).
// A paste stores content on Community (anonymous pastes keep their own address limit in pastes.js).
['live.paste.create', /^POST$/, /^\/pastes\/?$/, { minute: 30, hour: 600 }],
['live.paste.create', /^POST$/, /^\/pastes\/?$/, { minute: 30, hour: 600 }],
+ // Binding a channel to a Bot robot (server/bot/routes.js): set once, rarely changed.
+ ['live.bot.bind', /^PUT$/, /^\/streams\/channel\/[^/]+\/bot$/, { minute: 10, hour: 60 }],
['live.follow', /^(POST|DELETE)$/, /^\/streams\/(channel\/[^/]+|[^/]+)\/follow$/, { minute: 60, hour: 600 }],
['live.follow', /^(POST|DELETE)$/, /^\/streams\/(channel\/[^/]+|[^/]+)\/follow$/, { minute: 60, hour: 600 }],
// A new stream key invalidates the old one; nobody needs it more than a few times.
// A new stream key invalidates the old one; nobody needs it more than a few times.
['live.stream.key', /^POST$/, /^\/streams\/managed\/[^/]+\/regenerate-key$/, { minute: 5, hour: 20 }],
['live.stream.key', /^POST$/, /^\/streams\/managed\/[^/]+\/regenerate-key$/, { minute: 5, hour: 20 }],
@@ -511,6 +511,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -511,6 +511,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
delete publicChannel.stream_key;
delete publicChannel.stream_key;
delete publicChannel.vod_recording_enabled;
delete publicChannel.vod_recording_enabled;
delete publicChannel.force_vod_recording_disabled;
delete publicChannel.force_vod_recording_disabled;
+ // The binding is only shown through `bot_embed` below, and only with LIVE_BOT_EMBED on.
+ delete publicChannel.bot_robot_id;
+ const botEmbed = require('../bot/embed').channelEmbed(channel);
// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by
// Counts for tab badges. Owner/mods see hidden ones too. Pastes are counted by
// OpenVibe.Community and taken clips by OpenVibe.Media (media-proxy/lookups.js).
// OpenVibe.Community and taken clips by OpenVibe.Media (media-proxy/lookups.js).
@@ -581,6 +584,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -581,6 +584,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
aiClipsLimit,
aiClipsLimit,
aiClipsOffset,
aiClipsOffset,
aiClipsHasMore: aiClipsOffset + aiClips.length < aiClipsTotal,
aiClipsHasMore: aiClipsOffset + aiClips.length < aiClipsTotal,
+ ...(botEmbed !== undefined ? { bot_embed: botEmbed } : {}),
});
});
} catch (err) {
} catch (err) {
console.error('[Channels] Get error:', err.message);
console.error('[Channels] Get error:', err.message);
@@ -38,7 +38,7 @@ function publicManagedStream(ms) {
@@ -38,7 +38,7 @@ function publicManagedStream(ms) {
/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */
/** A channel row as anyone may see it. `weather_enabled` replaces the ZIP it is derived from. */
function publicChannel(ch) {
function publicChannel(ch) {
if (!ch || typeof ch !== 'object') return ch;
if (!ch || typeof ch !== 'object') return ch;
- const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled']);
+ const out = omit(ch, [...ALWAYS_SECRET, 'weather_zip', 'vod_recording_enabled', 'force_vod_recording_disabled', 'bot_robot_id']);
if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');
if (!('weather_enabled' in out)) out.weather_enabled = !!(ch.weather_zip && ch.weather_detail && ch.weather_detail !== 'off');
return out;
return out;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+/**
+ * Binding a channel to an OpenVibe.Bot robot (roadmap T15 R9, server/bot/).
+ *
+ * With LIVE_BOT_EMBED off the binding route answers 404 and the channel JSON has no `bot_embed` key.
+ * With it on, only the channel owner binds (mods and others 403, anonymous 401), bad ids are 400, null
+ * unbinds, and the channel GET carries { enabled, robot_id, url } built from LIVE_BOT_URL.
+ * The real routers run on a temp database; only sign-in is stubbed (`x-test-user` header).
+ *
+ * node test/bot-embed-binding.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-bot-embed-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+delete process.env.LIVE_BOT_EMBED;
+delete process.env.LIVE_BOT_URL;
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+const warnings = [];
+console.warn = (...a) => { warnings.push(a.join(' ')); };
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, role) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (?, ?, ?, ?, 'x', ?)`)
+ .run(id, username, username, `${username}@x`, role);
+addUser(1, 'rover', 'streamer');
+addUser(2, 'modguy', 'user');
+addUser(3, 'stranger', 'streamer');
+db.ensureChannel(1);
+const chan = db.getChannelByUserId(1);
+
+// modguy is a channel moderator of rover (read from Chat through the moderation client).
+const moderation = require('../server/chat/moderation-client');
+moderation.getChannelModeration = async (channelId) => ({ settings: {}, moderator_ids: channelId === chan.id ? [2] : [] });
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/streams', require('../server/bot/routes'));
+app.use('/api/streams', require('../server/streaming/routes'));
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }
+}
+
+(async () => {
+ const server = http.createServer(app).listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const call = (method, p, { user, body } = {}) => new Promise((resolve, reject) => {
+ const data = body === undefined ? null : JSON.stringify(body);
+ const headers = {};
+ if (user) headers['x-test-user'] = String(user);
+ if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }
+ const req = http.request({ host: '127.0.0.1', port: server.address().port, method, path: p, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, body: json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+ const channelGet = () => call('GET', '/api/streams/channel/rover?pollOnly=1');
+ const bind = (robot_id, user = 1) => call('PUT', '/api/streams/channel/rover/bot', { user, body: { robot_id } });
+
+ await check('flag off: PUT answers 404 as if the route did not exist, the column stays unset', async () => {
+ const r = await bind('rob_sim123');
+ assert.strictEqual(r.status, 404);
+ assert.deepStrictEqual(r.body, { error: 'Not found' });
+ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);
+ });
+
+ await check('flag off: the channel GET has no bot_embed key and no bot_robot_id even when one is stored', async () => {
+ raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_stored1', chan.id);
+ const r = await channelGet();
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.ok(!('bot_embed' in r.body), 'no bot_embed key');
+ assert.ok(!('bot_robot_id' in r.body.channel), 'no raw column on the public channel');
+ assert.ok(!r.text.includes('rob_stored1'));
+ raw.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(chan.id);
+ });
+
+ process.env.LIVE_BOT_EMBED = '1';
+
+ await check('flag on, unbound: bot_embed is enabled with no robot', async () => {
+ const r = await channelGet();
+ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });
+ });
+
+ await check('flag on: anonymous 401, non-owner 403, channel mod 403', async () => {
+ assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { body: { robot_id: 'rob_sim123' } })).status, 401);
+ assert.strictEqual((await bind('rob_sim123', 3)).status, 403);
+ assert.strictEqual((await bind('rob_sim123', 2)).status, 403);
+ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);
+ });
+
+ await check('flag on: bad ids are 400', async () => {
+ const bad = ['sim123', 'rob_', 'rob_ab', 'rob_has space', 'rob_../../x', 'rob_' + 'a'.repeat(61), 42, {}, ['rob_sim123']];
+ const statuses = await Promise.all(bad.map((b) => bind(b).then((r) => r.status)));
+ assert.deepStrictEqual(statuses, bad.map(() => 400));
+ assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: {} })).status, 400, 'missing robot_id');
+ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);
+ });
+
+ await check('flag on: unknown channel is 404', async () => {
+ const r = await call('PUT', '/api/streams/channel/nobody/bot', { user: 1, body: { robot_id: 'rob_sim123' } });
+ assert.strictEqual(r.status, 404);
+ });
+
+ await check('flag on: the owner binds rob_sim123 and the channel GET returns the embed', async () => {
+ const r = await bind('rob_sim123');
+ assert.strictEqual(r.status, 200, r.text);
+ const g = await channelGet();
+ assert.deepStrictEqual(g.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' });
+ assert.ok(!('bot_robot_id' in g.body.channel));
+ });
+
+ await check('a custom LIVE_BOT_URL changes the origin', async () => {
+ process.env.LIVE_BOT_URL = 'https://bot.example.test/';
+ assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://bot.example.test/panel/rob_sim123/embed');
+ process.env.LIVE_BOT_URL = 'http://localhost:3020';
+ assert.strictEqual((await channelGet()).body.bot_embed.url, 'http://localhost:3020/panel/rob_sim123/embed');
+ });
+
+ await check('a garbage LIVE_BOT_URL falls back to the default with a warning', async () => {
+ const embed = require('../server/bot/embed');
+ const garbage = ['not a url', 'http://bot.example.test', 'https://bot.example.test/path', 'https://u:[email protected]', 'javascript:alert(1)'];
+ const results = garbage.map((bad) => {
+ warnings.length = 0;
+ process.env.LIVE_BOT_URL = bad;
+ return [bad, embed.botOrigin(), warnings.some((w) => w.includes('LIVE_BOT_URL'))];
+ });
+ assert.deepStrictEqual(results, garbage.map((bad) => [bad, 'https://openvibe.bot', true]));
+ process.env.LIVE_BOT_URL = 'http://localhost:3020';
+ process.env.NODE_ENV = 'production';
+ assert.strictEqual(embed.botOrigin(), 'https://openvibe.bot', 'no http localhost in production');
+ process.env.NODE_ENV = 'test';
+ process.env.LIVE_BOT_URL = 'not a url';
+ assert.strictEqual((await channelGet()).body.bot_embed.url, 'https://openvibe.bot/panel/rob_sim123/embed');
+ delete process.env.LIVE_BOT_URL;
+ });
+
+ await check('null and empty string unbind; url is null', async () => {
+ const unbound = { enabled: true, robot_id: null, url: null };
+ await bind('rob_sim123');
+ const a = await bind(null);
+ assert.strictEqual(a.status, 200, a.text);
+ assert.deepStrictEqual(a.body.bot_embed, unbound);
+ assert.deepStrictEqual((await channelGet()).body.bot_embed, unbound);
+ await bind('rob_sim123');
+ const b = await bind('');
+ assert.strictEqual(b.status, 200, b.text);
+ assert.deepStrictEqual((await channelGet()).body.bot_embed, unbound);
+ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null);
+ });
+
+ await check('turning the flag off again hides the binding', async () => {
+ await bind('rob_sim123');
+ delete process.env.LIVE_BOT_EMBED;
+ assert.ok(!('bot_embed' in (await channelGet()).body));
+ assert.strictEqual((await bind(null)).status, 404);
+ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, 'rob_sim123', 'binding kept for when the flag returns');
+ });
+
+ server.close();
+ db.getDb().close();
+ [tmp, `${tmp}-wal`, `${tmp}-shm`].forEach((f) => { try { fs.unlinkSync(f); } catch { /* */ } });
+ if (failures) { console.log(`bot-embed-binding: ${failures} failed`); process.exit(1); }
+ console.log('bot-embed-binding: all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });