openvibers/openvibe.live Commits

Render the Bot robot panel iframe on bound channel pages

OP OpenVibers committed 4 Oct 2026 · 53e60b11e3 · 9 files, +450 −4

Asked: Part of a cross-repository initiative (init-t15-step-r9-first-half-dossi-1004T1836): T15 step R9, first half (dossier /home/workstation/openvibe/agents/ds/prep/T15.md row R9; gated on R8b, now merged as Bot "Add joystick, gamepad, latency meter, camera tiles and panel design"): a Live channel page …
Harness job init-t15-step-r9-firs-1836-s4-211922 (plan T15).

Changes

ddiff --git a/README.md b/README.md
iindex e0e819f2..b8d52d1d 100644
---- a/README.md
++++ b/README.md
@@@ -113,7 +113,7 @@ 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.
++**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. The owner binds from the dashboard's Controls tab (a "Bot robot id" field shown only with the flag on), and a bound channel's page shows the panel in a sandboxed, lazy iframe under the player (`public/js/bot-embed.js`, its own `botEmbed` feature, loaded only for a bound channel; the frame is removed when the route ends). Unbound channels and the flag off load nothing new.
| Variable | Default | Meaning |
| --- | --- | --- |
ddiff --git a/STATUS.json b/STATUS.json
iindex 85736831..04378765 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -29,7 +29,7 @@
"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",
-- "channel ↔ OpenVibe.Bot robot binding and the bot_embed panel URL behind LIVE_BOT_EMBED (off by default)"
++ "channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default)"
],
"updated": "2026-10-04"
}
ddiff --git a/public/features.json b/public/features.json
iindex 29ce051a..fcc88437 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -181,6 +181,11 @@
"aivCloneChatter"
]
},
++ "botEmbed": {
++ "js": [
++ "/js/bot-embed.js"
++ ]
++ },
"homeTour": {
"js": [
"/js/home-tour.js"
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 654f51b9..ddab5a83 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -107,6 +107,11 @@
<div class="controls-grid" id="controls-grid"></div>
</div>
</div>
++ <!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
++ <section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
++ <h3 id="ch-bot-heading">Robot</h3>
++ <div id="ch-bot-container"></div>
++ </section>
<!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->
<div class="ch-captions" id="ch-captions" style="display:none"></div>
<!-- Streamer Info Bar (below video, Kick/Twitch style) -->
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 9d8bc264..28112d3d 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -195,6 +195,19 @@
<div id="dash-chatlog-pagination" style="display:flex;gap:8px;justify-content:center;margin-top:8px"></div>
</div>
++ <!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
++ <div class="dash-card" id="dash-card-bot-embed" style="display:none">
++ <h3><i class="fa-solid fa-robot"></i> Bot robot</h3>
++ <div class="settings-row">
++ <label class="settings-row-label" for="dash-bot-robot-id">Bot robot id</label>
++ <div class="settings-row-content">
++ <input type="text" id="dash-bot-robot-id" placeholder="rob_…" autocomplete="off" spellcheck="false">
++ </div>
++ </div>
++ <button class="btn btn-primary btn-small" onclick="saveBotEmbedSetting()"><i class="fa-solid fa-floppy-disk"></i> Save</button>
++ <span id="dash-bot-robot-status" class="muted" style="font-size:0.82rem" role="status"></span>
++ </div>
++
<!-- ONVIF Camera Controls -->
<div class="dash-card" id="dash-card-cameras">
<h3><i class="fa-solid fa-video"></i> Camera Controls (ONVIF PTZ)</h3>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 7452130c..f579470b 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -670,6 +670,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_applyChannelLanguage(data.language, ch, liveStreams.length > 0);
_resetChannelTabs(ch);
_applyChannelTabMeta(data);
++ _applyBotEmbed(data.bot_embed);
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
// Reveal the Media Request tab if the streamer has it enabled (non-blocking).
_initMediaRequestTab(username);
@@@ -2198,6 +2199,23 @@ async function loadChannelAiTimeline(username) {
}
}
++// The channel's OpenVibe.Bot robot panel (roadmap T15 R9). `bot_embed` is only in the channel JSON with
++// LIVE_BOT_EMBED on, and public/js/bot-embed.js (feature "botEmbed") only loads for a bound channel, so with
++// the flag off this does nothing. The frame goes when the route ends, closing its socket to Bot.
++function _applyBotEmbed(botEmbed) {
++ const box = document.getElementById('ch-bot-container');
++ if (!box) return;
++ const show = () => {
++ if (typeof BotEmbed === 'undefined') return;
++ BotEmbed.mount(box, botEmbed);
++ if (window.ov) ov.scope().onDispose(() => BotEmbed.mount(box, null));
++ };
++ if (typeof BotEmbed !== 'undefined') return show();
++ if (!(botEmbed && botEmbed.url) || !window.ov) return;
++ const gen = ov.gen();
++ ov.load('botEmbed').then(() => { if (ov.isCurrent(gen)) show(); }, (e) => console.warn('[BotEmbed] load failed', e));
++}
++
// Apply per-channel tab metadata from the channel response: count badges on the
// Videos/Clips/Clips-Taken/Pastes tabs, and hide the Videos/Clips tabs entirely when the
// streamer keeps them private across all slots with nothing public to show.
ddiff --git a/public/js/bot-embed.js b/public/js/bot-embed.js
nnew file mode 100644
iindex 00000000..e6699fe6
---- /dev/null
++++ b/public/js/bot-embed.js
@@@ -0,0 +1,73 @@
++/*
++ OpenVibe.Live — a channel's OpenVibe.Bot robot panel (roadmap T15 R9). The channel JSON carries
++ `bot_embed: { enabled, robot_id, url }` only with LIVE_BOT_EMBED on (server/bot/embed.js); app-channel.js
++ loads this file (feature "botEmbed") only when that url is set, so with the flag off nothing here runs.
++
++ iframeAttrs(botEmbed) → the iframe's attributes, or null unless url is https (or http://localhost)
++ and ends in /embed
++ mount(container, botEmbed) → puts one iframe in container and unhides its section; removes it and hides
++ the section when there is nothing to show. Same url twice keeps the frame
++ (an operator's session survives a channel refresh).
++
++ Plain script for the browser (window.BotEmbed) and require() for the tests.
++*/
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.BotEmbed = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
++
++ function embedSrc(botEmbed) {
++ if (!botEmbed || typeof botEmbed.url !== 'string' || !botEmbed.url) return null;
++ let u;
++ try { u = new URL(botEmbed.url); } catch { return null; }
++ if (u.username || u.password) return null;
++ const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost');
++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
++ if (!/\/embed$/.test(u.pathname)) return null;
++ return u.href;
++ }
++
++ function iframeAttrs(botEmbed) {
++ const src = embedSrc(botEmbed);
++ if (!src) return null;
++ return {
++ src,
++ title: 'Robot control panel',
++ loading: 'lazy',
++ referrerpolicy: 'strict-origin-when-cross-origin',
++ allow: 'gamepad',
++ sandbox: SANDBOX,
++ };
++ }
++
++ function sectionOf(container) {
++ return (container.closest && container.closest('section')) || container.parentNode || null;
++ }
++
++ function mount(container, botEmbed) {
++ if (!container) return null;
++ const section = sectionOf(container);
++ const attrs = iframeAttrs(botEmbed);
++ if (!attrs) {
++ container.replaceChildren();
++ if (section) section.hidden = true;
++ return null;
++ }
++ const current = container.firstChild;
++ if (current && current.tagName === 'IFRAME' && current.getAttribute('src') === attrs.src) {
++ if (section) section.hidden = false;
++ return current;
++ }
++ const frame = container.ownerDocument.createElement('iframe');
++ for (const [k, v] of Object.entries(attrs)) frame.setAttribute(k, v);
++ container.replaceChildren(frame);
++ if (section) section.hidden = false;
++ return frame;
++ }
++
++ return { iframeAttrs, mount };
++});
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 2fe0f23d..230f7621 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {
home: ['dash-card-profile', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
-- controls: ['dash-card-controls', 'dash-card-cameras', 'dash-card-tokens'],
++ controls: ['dash-card-controls', 'dash-card-bot-embed', 'dash-card-cameras', 'dash-card-tokens'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-powerchat-card'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
@@@ -97,7 +97,7 @@ window.switchDashTab = switchDashTab;
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
--window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
++window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadBotEmbedSetting'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
@@@ -1004,6 +1004,34 @@ async function saveAiDerivationSetting(box) {
}
}
++/* ── Bot robot on the channel page (roadmap T15 R9) ──────────────────────────
++ The card shows only when the channel JSON carries bot_embed (LIVE_BOT_EMBED on). Saving PUTs
++ /api/streams/channel/<me>/bot; an empty id unbinds. */
++async function loadBotEmbedSetting() {
++ const card = document.getElementById('dash-card-bot-embed');
++ if (!card || !currentUser) return;
++ try {
++ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}?pollOnly=1`);
++ const be = data && data.bot_embed;
++ if (!be || !be.enabled) { card.style.display = 'none'; return; }
++ document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
++ card.style.display = '';
++ } catch { card.style.display = 'none'; }
++}
++async function saveBotEmbedSetting() {
++ const input = document.getElementById('dash-bot-robot-id');
++ const status = document.getElementById('dash-bot-robot-status');
++ if (!input || !currentUser) return;
++ const id = input.value.trim();
++ try {
++ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body: { robot_id: id || null } });
++ const bound = data && data.bot_embed && data.bot_embed.robot_id;
++ if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
++ } catch (e) {
++ if (status) status.textContent = e.message || 'Save failed';
++ }
++}
++
async function loadClipSettings() {
const el = document.getElementById('dash-clips-allow-creator-delete');
if (!el) return;
ddiff --git a/test/bot-embed-page.test.js b/test/bot-embed-page.test.js
nnew file mode 100644
iindex 00000000..b0c1f7ee
---- /dev/null
++++ b/test/bot-embed-page.test.js
@@@ -0,0 +1,304 @@
++/**
++ * The channel page's OpenVibe.Bot robot panel (roadmap T15 R9, public/js/bot-embed.js).
++ *
++ * (a) iframeAttrs/mount on a tiny fake DOM: the sim.rover embed yields one sandboxed iframe, a new url
++ * replaces it, the same url keeps it, and javascript:/data:/plain-http/non-embed/missing urls show nothing.
++ * (b) End to end with the real routers on a temp database: flag on + a channel bound to a sim.rover robot →
++ * the channel GET carries the url the iframe uses; flag off → no bot_embed, and the page code
++ * (app-channel.js's _applyBotEmbed, run against a fake ov) requests nothing and adds nothing.
++ * (c) The dashboard's binding card (dashboard.js) shows only with the flag on, saves, unbinds and shows server errors.
++ *
++ * node test/bot-embed-page.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-page-${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 BotEmbed = require('../public/js/bot-embed.js');
++
++const SIM = { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed' };
++const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
++
++// Just enough DOM for mount(): elements with attributes, children, hidden and a section parent.
++function fakeDom() {
++ const doc = {
++ created: 0,
++ createElement(tag) {
++ doc.created++;
++ const attrs = {};
++ return {
++ tagName: tag.toUpperCase(), ownerDocument: doc, attrs,
++ setAttribute(k, v) { attrs[k] = String(v); },
++ getAttribute(k) { return k in attrs ? attrs[k] : null; },
++ };
++ },
++ };
++ const section = { hidden: true };
++ const container = {
++ ownerDocument: doc, parentNode: section, children: [],
++ get firstChild() { return this.children[0] || null; },
++ replaceChildren(...nodes) { this.children = nodes; },
++ closest(sel) { return sel === 'section' ? section : null; },
++ };
++ return { doc, section, container };
++}
++
++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 () => {
++ await check('iframeAttrs: the sim.rover embed gives the exact iframe attributes', () => {
++ assert.deepStrictEqual(BotEmbed.iframeAttrs(SIM), {
++ src: 'https://openvibe.bot/panel/rob_sim123/embed',
++ title: 'Robot control panel',
++ loading: 'lazy',
++ referrerpolicy: 'strict-origin-when-cross-origin',
++ allow: 'gamepad',
++ sandbox: SANDBOX,
++ });
++ assert.strictEqual(BotEmbed.iframeAttrs({ url: 'http://localhost:3020/panel/rob_sim123/embed' }).src,
++ 'http://localhost:3020/panel/rob_sim123/embed', 'http://localhost is allowed for development');
++ });
++
++ await check('iframeAttrs: bad, unsafe and missing urls give null', () => {
++ const bad = [undefined, null, {}, { enabled: true, robot_id: null, url: null }, { url: '' }, { url: 42 },
++ { url: 'javascript:alert(1)//embed' }, { url: 'data:text/html,<b>x</b>/embed' },
++ { url: 'http://evil.example/panel/rob_sim123/embed' }, { url: 'https://openvibe.bot/panel/rob_sim123' },
++ { url: 'https://openvibe.bot/panel/rob_sim123/embed/x' }, { url: 'https://u:[email protected]/panel/rob_sim123/embed' },
++ { url: '//openvibe.bot/panel/rob_sim123/embed' }, { url: 'not a url' }];
++ assert.deepStrictEqual(bad.map((b) => BotEmbed.iframeAttrs(b)), bad.map(() => null));
++ });
++
++ await check('mount: one iframe with the embed src, section shown; same url keeps it, a new url replaces it', () => {
++ const { doc, section, container } = fakeDom();
++ const f1 = BotEmbed.mount(container, SIM);
++ assert.strictEqual(container.children.length, 1);
++ assert.strictEqual(f1.tagName, 'IFRAME');
++ assert.strictEqual(f1.getAttribute('src'), SIM.url);
++ assert.strictEqual(f1.getAttribute('sandbox'), SANDBOX);
++ assert.strictEqual(f1.getAttribute('allow'), 'gamepad');
++ assert.strictEqual(f1.getAttribute('title'), 'Robot control panel');
++ assert.strictEqual(section.hidden, false);
++ assert.strictEqual(BotEmbed.mount(container, SIM), f1, 'a refresh keeps the frame (and its session)');
++ assert.strictEqual(doc.created, 1);
++ const other = { enabled: true, robot_id: 'rob_other1', url: 'https://openvibe.bot/panel/rob_other1/embed' };
++ const f2 = BotEmbed.mount(container, other);
++ assert.notStrictEqual(f2, f1);
++ assert.deepStrictEqual(container.children, [f2]);
++ assert.strictEqual(f2.getAttribute('src'), other.url);
++ });
++
++ await check('mount: unbound, absent or unsafe embeds remove the frame and hide the section', () => {
++ for (const be of [undefined, { enabled: true, robot_id: null, url: null }, { url: 'javascript:alert(1)//embed' }, { url: 'http://evil.example/x/embed' }]) {
++ const { doc, section, container } = fakeDom();
++ BotEmbed.mount(container, SIM);
++ assert.strictEqual(BotEmbed.mount(container, be), null);
++ assert.deepStrictEqual(container.children, []);
++ assert.strictEqual(section.hidden, true);
++ assert.strictEqual(doc.created, 1);
++ }
++ assert.strictEqual(BotEmbed.mount(null, SIM), null, 'no container (another page) is a no-op');
++ });
++
++ await check('page wiring: the script is its own lazy feature and the section starts hidden', () => {
++ const features = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'features.json'), 'utf8')).features;
++ assert.deepStrictEqual(features.botEmbed.js, ['/js/bot-embed.js']);
++ const everywhere = Object.entries(features).filter(([n, f]) => n !== 'botEmbed' && JSON.stringify(f).includes('bot-embed'));
++ assert.deepStrictEqual(everywhere, [], 'no other feature pulls bot-embed.js in');
++ const index = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
++ assert.ok(!index.includes('bot-embed'), 'the shell does not load it');
++ const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
++ assert.match(frag, /<section id="ch-bot-section"[^>]*\bhidden\b[^>]*>/);
++ assert.match(frag, /<div id="ch-bot-container"><\/div>/);
++ assert.ok(!/<iframe/i.test(frag), 'no iframe in the markup');
++ const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
++ assert.strictEqual((app.match(/_applyBotEmbed\(/g) || []).length, 2, 'defined once, called once');
++ assert.ok(app.includes('_applyBotEmbed(data.bot_embed);'));
++ });
++
++ // ── (b) end to end through the real channel routes ──
++ 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(); };
++ raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) VALUES (1, 'rover', 'rover', 'rover@x', 'x', 'streamer')`).run();
++ db.ensureChannel(1);
++ const chan = db.getChannelByUserId(1);
++ const moderation = require('../server/chat/moderation-client');
++ moderation.getChannelModeration = async () => ({ settings: {}, moderator_ids: [] });
++
++ 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'));
++ const server = http.createServer(app).listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const call = (method, p, body) => new Promise((resolve, reject) => {
++ const data = body === undefined ? null : JSON.stringify(body);
++ const headers = { 'x-test-user': '1' };
++ 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');
++
++ // The page side: _applyBotEmbed with a fake ov that records loads, and BotEmbed defined once "loaded".
++ const page = () => {
++ const dom = fakeDom();
++ const loads = [];
++ const env = { BotEmbed: undefined, document: { getElementById: (id) => (id === 'ch-bot-container' ? dom.container : null) } };
++ const disposers = [];
++ env.window = { ov: true };
++ env.ov = {
++ gen: () => 1, isCurrent: () => true,
++ scope: () => ({ onDispose: (f) => disposers.push(f) }),
++ load: (name) => { loads.push(name); env.BotEmbed = BotEmbed; return Promise.resolve(); },
++ };
++ return { dom, loads, env, disposers };
++ };
++ // app-channel.js's _applyBotEmbed run on its own; `with` lets it see the fake document/ov/BotEmbed globals.
++ const runApply = (p, botEmbed) => {
++ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
++ const fn = src.match(/\nfunction _applyBotEmbed\(botEmbed\) \{[\s\S]*?\n\}\n/)[0];
++ // eslint-disable-next-line no-new-func
++ new Function('env', 'botEmbed', `with (env) { ${fn}; _applyBotEmbed(botEmbed); }`)(p.env, botEmbed);
++ return new Promise((r) => setImmediate(r));
++ };
++
++ try {
++ await check('flag off: no bot_embed key, and the page loads nothing and adds no frame', async () => {
++ raw.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run('rob_sim123', chan.id);
++ const r = await channelGet();
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(!('bot_embed' in r.body));
++ const p = page();
++ await runApply(p, r.body.bot_embed);
++ assert.deepStrictEqual(p.loads, [], 'no request for bot-embed.js');
++ assert.deepStrictEqual(p.dom.container.children, []);
++ assert.strictEqual(p.dom.section.hidden, true);
++ assert.strictEqual(p.dom.doc.created, 0);
++ });
++
++ process.env.LIVE_BOT_EMBED = '1';
++
++ await check('flag on, unbound: the page still loads nothing', async () => {
++ assert.strictEqual((await call('PUT', '/api/streams/channel/rover/bot', { robot_id: null })).status, 200);
++ const r = await channelGet();
++ assert.deepStrictEqual(r.body.bot_embed, { enabled: true, robot_id: null, url: null });
++ const p = page();
++ await runApply(p, r.body.bot_embed);
++ assert.deepStrictEqual(p.loads, []);
++ assert.strictEqual(p.dom.section.hidden, true);
++ });
++
++ await check('flag on, bound to a sim.rover robot: the channel page renders the Bot panel iframe', async () => {
++ const put = await call('PUT', '/api/streams/channel/rover/bot', { robot_id: 'rob_sim123' });
++ assert.strictEqual(put.status, 200, put.text);
++ const r = await channelGet();
++ assert.deepStrictEqual(r.body.bot_embed, SIM);
++ const p = page();
++ await runApply(p, r.body.bot_embed);
++ assert.deepStrictEqual(p.loads, ['botEmbed']);
++ assert.strictEqual(p.dom.container.children.length, 1);
++ const frame = p.dom.container.children[0];
++ assert.strictEqual(frame.tagName, 'IFRAME');
++ assert.strictEqual(frame.getAttribute('src'), 'https://openvibe.bot/panel/rob_sim123/embed');
++ assert.strictEqual(frame.getAttribute('sandbox'), SANDBOX);
++ assert.strictEqual(p.dom.section.hidden, false);
++ assert.strictEqual(p.disposers.length, 1);
++ p.disposers[0]();
++ assert.deepStrictEqual(p.dom.container.children, [], 'leaving the route removes the frame');
++ assert.strictEqual(p.dom.section.hidden, true);
++ });
++
++ // The dashboard's binding card (public/js/dashboard.js), run against the real routes through a fake api().
++ const dash = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'dashboard.js'), 'utf8');
++ const dashFns = ['loadBotEmbedSetting', 'saveBotEmbedSetting'].map((n) => {
++ const m = dash.match(new RegExp(`\\nasync function ${n}\\(\\) \\{[\\s\\S]*?\\n\\}\\n`));
++ assert.ok(m, `${n} is in dashboard.js`);
++ return m[0];
++ }).join('\n');
++ const dashboard = () => {
++ const els = {
++ 'dash-card-bot-embed': { style: { display: 'none' } },
++ 'dash-bot-robot-id': { value: '' },
++ 'dash-bot-robot-status': { textContent: '' },
++ };
++ const env = {
++ currentUser: { id: 1, username: 'rover' },
++ document: { getElementById: (id) => els[id] || null },
++ api: async (p, opts = {}) => {
++ const r = await call(opts.method || 'GET', `/api${p}`, opts.body);
++ if (r.status >= 400) throw { status: r.status, message: (r.body && r.body.error) || 'Request failed' };
++ return r.body;
++ },
++ };
++ // eslint-disable-next-line no-new-func
++ const fns = new Function('env', `with (env) { ${dashFns}; return { loadBotEmbedSetting, saveBotEmbedSetting }; }`)(env);
++ return { els, ...fns };
++ };
++
++ await check('dashboard: the binding card shows the bound id and saves, unbinds and reports server errors', async () => {
++ const d = dashboard();
++ await d.loadBotEmbedSetting();
++ assert.strictEqual(d.els['dash-card-bot-embed'].style.display, '');
++ assert.strictEqual(d.els['dash-bot-robot-id'].value, 'rob_sim123');
++ d.els['dash-bot-robot-id'].value = 'not-a-robot';
++ await d.saveBotEmbedSetting();
++ assert.strictEqual(d.els['dash-bot-robot-status'].textContent, 'robot_id must be a Bot robot id (rob_…) or null');
++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, 'rob_sim123');
++ d.els['dash-bot-robot-id'].value = ' ';
++ await d.saveBotEmbedSetting();
++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, null, 'an empty id unbinds');
++ d.els['dash-bot-robot-id'].value = ' rob_sim123 ';
++ await d.saveBotEmbedSetting();
++ assert.strictEqual(db.getChannelByUserId(1).bot_robot_id, 'rob_sim123');
++ assert.strictEqual(d.els['dash-bot-robot-status'].textContent, 'Bound to rob_sim123');
++ });
++
++ await check('dashboard, flag off: the binding card stays hidden', async () => {
++ delete process.env.LIVE_BOT_EMBED;
++ const d = dashboard();
++ await d.loadBotEmbedSetting();
++ assert.strictEqual(d.els['dash-card-bot-embed'].style.display, 'none');
++ });
++ } finally {
++ delete process.env.LIVE_BOT_EMBED;
++ server.close();
++ db.getDb().close();
++ [tmp, `${tmp}-wal`, `${tmp}-shm`].forEach((f) => { try { fs.unlinkSync(f); } catch { /* */ } });
++ }
++
++ if (failures) { console.log(`bot-embed-page: ${failures} failed`); process.exit(1); }
++ console.log('bot-embed-page: all passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });