openvibers/openvibe.live Commits
A robot's Drive under the stream works for viewers signed in here (a ticket from Bot through the channel page, no popup), viewers no longer see an empty robot box, and chat commands press a button robot's same-named buttons (#383)
Changes
@@@ -108,9 +108,9 @@ Live's own principal (`live`) holds grants to call:API writes are limited per person (`server/net/actor-limits.js`, `LIVE_LIMITS_MINUTE` / `_HOUR`).--**Robots are OpenVibe.Bot's (plan T15).** Live has no control socket, hardware API or ONVIF code. A bound channel's chat commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) reach `POST /internal/chat-effects/hardware` from OpenVibe.Chat and go to Bot as the viewer who typed them (`server/bot/bot-client.js`): Bot's roles, queue, cooldowns and e-stop decide.++**Robots are OpenVibe.Bot's (plan T15).** Live has no control socket, hardware API or ONVIF code. A bound channel's chat commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) reach `POST /internal/chat-effects/hardware` from OpenVibe.Chat and go to Bot as the viewer who typed them (`server/bot/bot-client.js`): Bot's roles, queue, cooldowns and e-stop decide. On a robot made of buttons (Bot's generic relay or a local profile), which has no drive or actuator, the chat word is pressed as its button of the same name (`forward`, `turn_left`, `head_up` …; a hold button is pressed for the 600 ms pulse and released), and `button:<name>` (Chat's `!<word>` that is not a channel sound) presses any other button.--**Bot panel embed (`LIVE_BOT_EMBED`, off by default; on in production).** 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.++**Bot panel embed (`LIVE_BOT_EMBED`, off by default; on in production).** 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. The easier way to bind is from Bot: the robot page's **Put the buttons under my OpenVibe.Live stream** turns its embedding on and opens the owner's channel with `?bind_robot=rob_…`, where they confirm (`public/js/bot-bind.js`). The panel's Drive asks the page around it for a ticket (`postMessage` `ov-bot-ticket-request`, answered only to that frame on Bot's origin): `POST /api/streams/channel/:username/bot/ticket` `{ robot_id }` (signed in; the channel's own robot only; limited as `live.bot.ticket`) gets one from Bot as the viewer (`controlTicket`, `bot.robot.control`), so a viewer signed in here drives with no popup or second sign-in; Bot's refusal is said in the panel. When the robot's owner has not turned the panel on, Bot's framed refusal tells the page, which hides the box from viewers (the owner keeps it, with the link to the switch).| Variable | Default | Meaning || --- | --- | --- |@@@ -88,7 +88,8 @@<!-- 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> Your robot</h3>-- <p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera) and put its id here. Its live panel shows under your stream, and chat drives it with <code>!forward</code>, <code>!backward</code>, <code>!left</code>, <code>!right</code>, <code>!headup</code>, <code>!liftup</code> and <code>!say</code>. Who may drive is the robot's setting on OpenVibe.Bot: only you, people you invite, or anyone signed in taking turns.</p>++ <p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>++ <p class="muted" style="font-size:0.85rem">Viewers signed in here press Drive on the panel. Chat drives it with <code>!forward</code>, <code>!backward</code>, <code>!left</code>, <code>!right</code>, <code>!headup</code>, <code>!liftup</code> and <code>!say</code>; on a robot made of buttons, <code>!</code> and a button's name presses it. Who may drive is the robot's setting on OpenVibe.Bot (beside that button): only you and people you add, people you invite, or anyone signed in taking turns.</p><div class="settings-row"><label class="settings-row-label" for="dash-bot-robot-id">Robot id</label><div class="settings-row-content">@@@ -762,7 +762,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI_applyChannelLanguage(data.language, ch, liveStreams.length > 0);_resetChannelTabs(ch);_applyChannelTabMeta(data);-- _applyBotEmbed(data.bot_embed);++ _applyBotEmbed(data.bot_embed, username);_offerBotBind(username);_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab// Reveal the Media Request tab if the streamer has it enabled (non-blocking).@@@ -2305,13 +2305,35 @@ 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) {++// The panel's Drive asks this page for a ticket: a viewer signed in here drives with no popup or second sign-in++// (POST /api/streams/channel/:username/bot/ticket; Bot decides who may). When the robot's owner has not turned the++// panel on, viewers do not see an empty box; the owner keeps the frame, which links to the switch on openvibe.bot.++let _botEmbedStop = null;++function _applyBotEmbed(botEmbed, username) {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));++ const frame = BotEmbed.mount(box, botEmbed);++ if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }++ if (frame && username && typeof BotEmbed.listen === 'function') {++ const robotId = botEmbed.robot_id;++ _botEmbedStop = BotEmbed.listen({++ frame, botEmbed,++ requestTicket: async () => {++ if (!currentUser) return { message: 'Sign in on OpenVibe.Live to drive the robot.' };++ try {++ return await api(`/streams/channel/${encodeURIComponent(username)}/bot/ticket`, { method: 'POST', body: { robot_id: robotId } });++ } catch (e) { return { message: (e && e.message) || 'You cannot drive this robot from here.' }; }++ },++ onRefused: () => {++ if (currentUser && currentUser.username === username) return;++ if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }++ BotEmbed.mount(box, null);++ },++ });++ }++ if (window.ov) ov.scope().onDispose(() => { if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; } BotEmbed.mount(box, null); });};if (typeof BotEmbed !== 'undefined') return show();if (!(botEmbed && botEmbed.url) || !window.ov) return;@@@ -2329,7 +2351,7 @@ function _offerBotBind(username) {ov.load('botBind').then(() => {if (!ov.isCurrent(gen) || typeof BotBind === 'undefined') return;const robotId = BotBind.robotFrom(location.search);-- if (robotId) BotBind.offer({ username, robotId, api, toast, onBound: _applyBotEmbed });++ if (robotId) BotBind.offer({ username, robotId, api, toast, onBound: (be) => _applyBotEmbed(be, username) });}, (e) => console.warn('[BotBind] load failed', e));}@@@ -8,6 +8,13 @@mount(container, botEmbed) → puts one iframe in container and unhides its section; removes it and hidesthe section when there is nothing to show. Same url twice keeps the frame(an operator's session survives a channel refresh).++ listen({ frame, botEmbed, requestTicket, onRefused }) → stop(): answers the framed panel (OpenVibe.Bot++ public/panel.js and refused.js), only from that frame and Bot's origin, for++ this robot only:++ ov-bot-ticket-request → await requestTicket() ({ ticket } or { message }) →++ ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without a++ popup or a second sign-in on openvibe.bot++ ov-bot-embed-refused → onRefused(): the owner has not turned the panel onPlain script for the browser (window.BotEmbed) and require() for the tests.*/@@@ -69,5 +76,33 @@return frame;}-- return { iframeAttrs, mount };++ function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {++ const src = embedSrc(botEmbed);++ if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};++ const origin = new URL(src).origin;++ const robotId = botEmbed.robot_id;++ let asking = false;++ const reply = (msg) => {++ try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }++ };++ const onMessage = async (e) => {++ if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;++ const d = e.data || {};++ if (!d || d.robot_id !== robotId) return;++ if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }++ if (d.type !== 'ov-bot-ticket-request' || asking) return;++ asking = true;++ try {++ const out = await requestTicket();++ if (out && typeof out.ticket === 'string') reply({ type: 'ov-bot-ticket', ticket: out.ticket });++ else reply({ type: 'ov-bot-ticket-none', message: String((out && out.message) || 'You cannot drive this robot from here.').slice(0, 200) });++ } catch (err) {++ reply({ type: 'ov-bot-ticket-none', message: String((err && err.message) || 'You cannot drive this robot from here.').slice(0, 200) });++ } finally { asking = false; }++ };++ win.addEventListener('message', onMessage);++ return () => win.removeEventListener('message', onMessage);++ }++++ return { iframeAttrs, mount, listen };});@@@ -5,6 +5,10 @@** command(robotId, subject, { id, kind, value, ms }) → Bot's command_result ({ result, … }); a refusal throws* a BotRefusal with Bot's code and reason++ * controlTicket(robotId, subject) → { ticket, robot_id, role, expires_in }: a one-time Drive++ * ticket for the robot's panel framed on the channel page,++ * so a viewer signed in here drives with no popup or second++ * sign-in (Bot's POST /api/v1/robots/:id/control-ticket)** The token is Live's client-credentials token for audience openvibe.bot (Network's grant to `live`:* bot.robot.control); X-OV-Subject names the viewer, so Bot's roles, queue, cooldowns and e-stop apply to them.@@@ -44,16 +48,17 @@ function serviceTokens() {return tokens;}--async function command(robotId, subject, { id, kind, value, ms }) {++/** POST to a robot's route as the viewer: Bot's answer, or a BotRefusal / BotUnavailable. */++async function post(robotId, route, subject, body) {if (drill()) throw new BotUnavailable('restore drill: nothing is sent');const client = serviceTokens();if (!client) throw new BotUnavailable('OV_OAUTH_CLIENT_SECRET is not set');let res;try {-- res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/commands`, {++ res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/${route}`, {method: 'POST',headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${await client.getToken()}`, 'X-OV-Subject': subject },-- body: JSON.stringify({ id, kind, value, ms }),++ body: JSON.stringify(body),signal: AbortSignal.timeout(TIMEOUT_MS),});} catch (err) { note(err); throw new BotUnavailable(err.message); }@@@ -65,4 +70,7 @@ async function command(robotId, subject, { id, kind, value, ms }) {return out;}--module.exports = { command, BotRefusal, BotUnavailable, AUDIENCE };++const command = (robotId, subject, { id, kind, value, ms }) => post(robotId, 'commands', subject, { id, kind, value, ms });++const controlTicket = (robotId, subject) => post(robotId, 'control-ticket', subject, {});++++module.exports = { command, controlTicket, BotRefusal, BotUnavailable, AUDIENCE };@@@ -1,9 +1,13 @@/*** 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++ * PUT /api/streams/channel/:username/bot { robot_id: 'rob_…' | null } — the channel owner only++ * POST /api/streams/channel/:username/bot/ticket { robot_id } — any signed-in viewer: a Drive ticket for the++ * channel's robot, asked by its panel framed on the page (public/js/bot-embed.js listen), so a viewer signed in++ * here drives with no popup or second sign-in on openvibe.bot. Bot decides who may (a member, or anyone on a++ * robot that takes turns): a refusal answers 403 with its words.*-- * With LIVE_BOT_EMBED off the route answers 404 as if it did not exist. Binding does not call Bot: the++ * With LIVE_BOT_EMBED off the routes answer 404 as if they 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';@@@ -38,4 +42,35 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next}});++// What Bot's refusal means, said to the viewer in the panel (Bot's codes, OpenVibe.Bot docs/protocol.md §3).++const TICKET_REFUSALS = {++ 'bot.not_an_operator': 'The robot\'s owner decides who may drive it.',++ 'bot.robot_not_found': 'This channel\'s robot no longer exists.',++};++++router.post('/channel/:username/bot/ticket', (req, res, next) => (embed.enabled() ? next() : notFound(res)), requireAuth, async (req, res) => {++ res.setHeader('Cache-Control', 'no-store');++ try {++ const channel = await db.getChannelByUsername(req.params.username);++ const robotId = channel && embed.validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;++ // Only the robot this channel shows: the page cannot ask Live for tickets to other robots.++ if (!robotId || (req.body && req.body.robot_id !== robotId)) return res.status(404).json({ error: 'This channel shows no such robot' });++ const subject = await require('../auth/identity-sync').subjectOf(req.user.id);++ if (!subject) return res.status(409).json({ error: 'Sign in again on OpenVibe.Live to drive the robot.' });++ const bot = require('./bot-client');++ try {++ const t = await bot.controlTicket(robotId, subject);++ return res.json({ ticket: t.ticket, robot_id: robotId, role: t.role || null, expires_in: t.expires_in || null });++ } catch (err) {++ if (err instanceof bot.BotRefusal) {++ return res.status(403).json({ error: TICKET_REFUSALS[err.code] || 'You cannot drive this robot from here.', code: err.code });++ }++ return res.status(503).json({ error: 'The robot service did not answer. Try again in a moment.' });++ }++ } catch (err) {++ console.error('[BotEmbed] Ticket error:', err.message);++ res.status(500).json({ error: 'Could not ask for the controls' });++ }++});++module.exports = router;@@@ -498,6 +498,14 @@ const CHAT_ROBOT_COMMANDS = {head_up: { kind: 'actuator', value: { name: 'head', value: 1 } },head_down: { kind: 'actuator', value: { name: 'head', value: -1 } },};++// A robot made of buttons (OpenVibe.Bot's generic relay, or a local profile: Maticus's Cozmo Hobo, 2026-10-26, "chat++// commands do nothing on my button robot") has no drive or actuator, and Bot refuses those as bot.command_not_allowed++// before anything is spent. The chat word is then pressed as the robot's button of the same name (forward, turn_left,++// head_up …), and `button:<name>` (OpenVibe.Chat's !<word> that is not a channel sound) presses any other button. A++// hold button needs a press and a release: Bot says so (bot.invalid_input) to a plain press, the answer is remembered++// per robot, and the release follows the pulse.++const BUTTON_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;++const buttonHold = new Map(); // `${robot}:${name}` → true for a hold buttonconst CHAT_ROBOT_MS = 600;const HARDWARE_COOLDOWN_MS = 250;const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -514,10 +522,31 @@ const ROBOT_REFUSALS = {'bot.unknown_actuator': 'This robot does not do that.','bot.robot_not_found': 'This channel\'s robot no longer exists.',};++async function pressButton(bot, robotId, subject, name) {++ const key = `${robotId}:${name}`;++ const press = (hold) => bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, kind: 'button', value: hold ? { name, state: 'down' } : { name }, ...(hold ? { ms: CHAT_ROBOT_MS } : {}) });++ let hold = buttonHold.get(key) === true;++ let out;++ try { out = await press(hold); } catch (err) {++ if (hold || !(err instanceof bot.BotRefusal) || err.code !== 'bot.invalid_input') throw err;++ hold = true;++ buttonHold.set(key, true);++ if (buttonHold.size > 2000) buttonHold.delete(buttonHold.keys().next().value);++ out = await press(true);++ }++ if (hold) {++ // Bot stops it at the deadline anyway; the release ends it on time and frees the button for the next press.++ setTimeout(() => bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, kind: 'button', value: { name, state: 'up' } }).catch(() => {}), CHAT_ROBOT_MS).unref();++ }++ return out;++}++effectsRouter.post('/hardware', async (req, res) => {const command = String(req.body?.command || '');const say = /^say:([\s\S]{1,200})$/.exec(command);-- const mapped = say ? { kind: 'say', value: { text: say[1] } } : CHAT_ROBOT_COMMANDS[command];++ const button = /^button:(.+)$/.exec(command);++ if (button && !BUTTON_NAME_RE.test(button[1])) return fail(res, 400, 'unknown command');++ const mapped = say ? { kind: 'say', value: { text: say[1] } } : button ? { kind: 'button', name: button[1] } : CHAT_ROBOT_COMMANDS[command];if (!mapped) return fail(res, 400, 'unknown command');const user = await db.getUserById(int(req.body.streamer_user_id));if (!user) return res.json({ ok: false, reason: 'no_user' });@@@ -532,8 +561,19 @@ effectsRouter.post('/hardware', async (req, res) => {hardwareLast.set(key, now);if (hardwareLast.size > 5000) hardwareLast.delete(hardwareLast.keys().next().value);const bot = require('../bot/bot-client');++ const robotId = channel.bot_robot_id;try {-- const out = await bot.command(channel.bot_robot_id, subject, { id: `chat-${require('crypto').randomUUID()}`, ...mapped, ...(mapped.kind === 'say' ? {} : { ms: CHAT_ROBOT_MS }) });++ let out;++ if (mapped.kind === 'button') out = await pressButton(bot, robotId, subject, mapped.name);++ else {++ try {++ out = await bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, ...mapped, ...(mapped.kind === 'say' ? {} : { ms: CHAT_ROBOT_MS }) });++ } catch (err) {++ if (!(err instanceof bot.BotRefusal) || err.code !== 'bot.command_not_allowed' || mapped.kind === 'say') throw err;++ // No drive or actuator here: its button of the same name, if it has one; else the first refusal stands.++ out = await pressButton(bot, robotId, subject, command).catch((again) => { throw again instanceof bot.BotRefusal && again.code === 'bot.command_not_allowed' ? err : again; });++ }++ }return res.json({ ok: true, result: out.result || null });} catch (err) {if (err instanceof bot.BotRefusal) {@@@ -41,6 +41,8 @@ const ROUTES = [['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 }],++ // A Drive ticket for the channel's robot panel: one per press of Drive (a reconnect gets its next one from Bot).++ ['live.bot.ticket', /^POST$/, /^\/streams\/channel\/[^/]+\/bot\/ticket$/, { minute: 20, hour: 300 }],['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 }],@@@ -7,6 +7,10 @@* 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.++ * (d) Drive from the framed panel (Maticus, 2026-10-26: driving took a popup and a second sign-in): listen() answers++ * the panel's ticket request and refusal only from that frame and Bot's origin, for this robot; and++ * POST /api/streams/channel/:username/bot/ticket asks Bot (a stub) for a ticket as the viewer, for the channel's++ * own robot only, saying Bot's refusal in plain words.** node test/bot-embed-page.test.js*/@@@ -124,8 +128,9 @@ async function check(name, fn) {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);'));++ assert.strictEqual((app.match(/_applyBotEmbed\(/g) || []).length, 3, 'defined once, called for the channel and after binding');++ assert.ok(app.includes('_applyBotEmbed(data.bot_embed, username);'));++ assert.ok(app.includes('onBound: (be) => _applyBotEmbed(be, username)'));});// ── (b) end to end through the real channel routes ──@@@ -154,14 +159,14 @@ async function check(name, fn) {app.use('/api/streams', require('../server/streaming/routes'));const server = http.createServer(app).listen(0, '127.0.0.1');if (!server.listening) await new Promise((r) => server.once('listening', r));-- const call = (method, p, body) => new Promise((resolve, reject) => {++ const call = (method, p, body, user = '1') => new Promise((resolve, reject) => {const data = body === undefined ? null : JSON.stringify(body);-- const headers = { 'x-test-user': '1' };++ const headers = user ? { 'x-test-user': 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 }); });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, body: json, text, headers: res.headers }); });});req.on('error', reject);if (data) req.write(data);@@@ -173,7 +178,7 @@ async function check(name, fn) {const page = () => {const dom = fakeDom();const loads = [];-- const env = { BotEmbed: undefined, document: { getElementById: (id) => (id === 'ch-bot-container' ? dom.container : null) } };++ const env = { BotEmbed: undefined, _botEmbedStop: null, currentUser: null, api: async () => ({}), document: { getElementById: (id) => (id === 'ch-bot-container' ? dom.container : null) } };const disposers = [];env.window = { ov: true };env.ov = {@@@ -186,9 +191,9 @@ async function check(name, fn) {// 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];++ const fn = src.match(/\nfunction _applyBotEmbed\(botEmbed, username\) \{[\s\S]*?\n\}\n/)[0];// eslint-disable-next-line no-new-func-- new Function('env', 'botEmbed', `with (env) { ${fn}; _applyBotEmbed(botEmbed); }`)(p.env, botEmbed);++ new Function('env', 'botEmbed', `with (env) { ${fn}; _applyBotEmbed(botEmbed, 'rover'); }`)(p.env, botEmbed);return new Promise((r) => setImmediate(r));};@@@ -283,6 +288,83 @@ async function check(name, fn) {assert.strictEqual(d.els['dash-bot-robot-status'].textContent, 'Bound to rob_sim123');});++ await check('listen(): the panel\'s ticket request is answered to Bot\'s origin; refusals and strangers are not', async () => {++ const listeners = new Set();++ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };++ const posted = [];++ const panel = { postMessage: (msg, origin) => posted.push([msg, origin]) };++ const frame = { contentWindow: panel };++ let answer = { ticket: 'T'.repeat(43) };++ let asked = 0;++ let refused = 0;++ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => { asked++; return answer; }, onRefused: () => { refused++; } });++ const send = async (data, { origin = 'https://openvibe.bot', source = panel } = {}) => {++ for (const f of [...listeners]) await f({ data, origin, source });++ };++ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' }, { origin: 'https://evil.example' });++ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' }, { source: {} });++ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_other' });++ assert.strictEqual(asked, 0, 'another origin, another window or another robot is ignored');++ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' });++ assert.deepStrictEqual(posted.at(-1), [{ robot_id: 'rob_sim123', type: 'ov-bot-ticket', ticket: 'T'.repeat(43) }, 'https://openvibe.bot']);++ answer = { message: 'Sign in on OpenVibe.Live to drive the robot.' };++ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' });++ assert.deepStrictEqual(posted.at(-1)[0], { robot_id: 'rob_sim123', type: 'ov-bot-ticket-none', message: 'Sign in on OpenVibe.Live to drive the robot.' });++ await send({ type: 'ov-bot-embed-refused', robot_id: 'rob_sim123' }, { origin: 'https://evil.example' });++ assert.strictEqual(refused, 0);++ await send({ type: 'ov-bot-embed-refused', robot_id: 'rob_sim123' });++ assert.strictEqual(refused, 1);++ stop();++ assert.strictEqual(listeners.size, 0, 'stop() removes the listener');++ assert.strictEqual(typeof BotEmbed.listen({ frame, botEmbed: { url: 'javascript:alert(1)' }, win, requestTicket: async () => ({}) }), 'function');++ assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');++ });++++ await check('POST …/bot/ticket: a Drive ticket from Bot as the viewer, for the channel\'s own robot only', async () => {++ const seen = [];++ let reply = { status: 200, body: { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 } };++ const stub = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));++ seen.push({ url: req.url, method: req.method, auth: req.headers.authorization, subject: req.headers['x-ov-subject'] });++ res.statusCode = reply.status;++ res.end(JSON.stringify(reply.body));++ });++ });++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));++ const stubUrl = `http://127.0.0.1:${stub.address().port}`;++ const ENV = ['OV_OAUTH_CLIENT_SECRET', 'OV_NETWORK_INTERNAL_URL', 'LIVE_BOT_INTERNAL_URL'];++ const before = Object.fromEntries(ENV.map((k) => [k, process.env[k]]));++ Object.assign(process.env, { OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_NETWORK_INTERNAL_URL: stubUrl, LIVE_BOT_INTERNAL_URL: stubUrl });++ const ticket = (body, user) => call('POST', '/api/streams/channel/rover/bot/ticket', body, user);++ try {++ delete process.env.LIVE_BOT_EMBED;++ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 404, 'flag off: no such route');++ process.env.LIVE_BOT_EMBED = '1';++ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' }, null)).status, 401, 'signed in only');++ assert.strictEqual((await ticket({ robot_id: 'rob_other1' })).status, 404, 'not the channel\'s robot');++ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 409, 'no Network subject linked');++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '901', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run();++ const ok = await ticket({ robot_id: 'rob_sim123' });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.deepStrictEqual(ok.body, { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 });++ assert.strictEqual(ok.headers['cache-control'], 'no-store');++ assert.deepStrictEqual(seen.at(-1), { url: '/api/v1/robots/rob_sim123/control-ticket', method: 'POST', auth: 'Bearer live-bot-token', subject: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' });++ reply = { status: 403, body: { code: 'bot.not_an_operator', detail: 'the owner decides who may drive this robot' } };++ const no = await ticket({ robot_id: 'rob_sim123' });++ assert.deepStrictEqual([no.status, no.body.code, no.body.error], [403, 'bot.not_an_operator', 'The robot\'s owner decides who may drive it.']);++ reply = { status: 503, body: {} };++ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 503, 'Bot down is said so');++ } finally {++ stub.close();++ await raw.prepare("DELETE FROM linked_accounts WHERE user_id = 1 AND service_user_id = '901'").run();++ for (const k of ENV) { if (before[k] === undefined) delete process.env[k]; else process.env[k] = before[k]; }++ }++ });++await check('dashboard, flag off: the binding card stays hidden', async () => {delete process.env.LIVE_BOT_EMBED;const d = dashboard();@@@ -1045,9 +1045,11 @@ const waitFor = async (pred, what, ms = 3000) => {req.on('end', () => {res.setHeader('Content-Type', 'application/json');if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));-- seen.push({ url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body: JSON.parse(raw || '{}') });-- res.statusCode = answer.status;-- return res.end(JSON.stringify(answer.body));++ const body = JSON.parse(raw || '{}');++ seen.push({ url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body });++ const a = typeof answer === 'function' ? answer(body) : answer;++ res.statusCode = a.status;++ return res.end(JSON.stringify(a.body));});});await new Promise((r) => stub.listen(0, '127.0.0.1', r));@@@ -1085,6 +1087,44 @@ const waitFor = async (pred, what, ms = 3000) => {await pause();assert.strictEqual((await hw({ from_user_id: viewer })).body.reason, 'robot_unavailable', 'Bot down is said so');assert.strictEqual((await hw({ from_user_id: viewer, command: 'self_destruct' })).status, 400, 'an unknown command');++ // A robot made of buttons (Maticus's Cozmo Hobo: forward, turn_left, head_up … ir_light): Bot refuses drive++ // and actuator before anything is spent, and the chat word is pressed as its button of that name. A hold++ // button (Bot: "a hold button needs down or up") is pressed down for the pulse and released after it.++ const NOT_ALLOWED = { status: 422, body: { code: 'bot.command_not_allowed', detail: 'not on this robot' } };++ const BUTTONS = ['forward', 'turn_left', 'head_up', 'ir_light'];++ answer = (b) => {++ if (b.kind !== 'button' || !BUTTONS.includes(b.value.name)) return NOT_ALLOWED;++ if (b.value.name === 'head_up' && !b.value.state) return { status: 422, body: { code: 'bot.invalid_input', detail: 'a hold button needs down or up' } };++ return { status: 200, body: { result: 'ack' } };++ };++ await pause();++ const n0 = seen.length;++ const pressed = await hw({ from_user_id: viewer, command: 'forward' });++ assert.strictEqual(pressed.body.ok, true, JSON.stringify(pressed.body));++ assert.deepStrictEqual(seen.slice(n0).map((x) => [x.body.kind, x.body.value, x.body.ms]), [['drive', { throttle: 0.6 }, 600], ['button', { name: 'forward' }, undefined]]);++ await pause();++ const n1 = seen.length;++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'head_up' })).body.ok, true);++ await new Promise((r) => setTimeout(r, 700));++ assert.deepStrictEqual(seen.slice(n1).map((x) => [x.body.kind, x.body.value, x.body.ms]), [++ ['actuator', { name: 'head', value: 1 }, 600], ['button', { name: 'head_up' }, undefined],++ ['button', { name: 'head_up', state: 'down' }, 600], ['button', { name: 'head_up', state: 'up' }, undefined]]);++ const n2 = seen.length;++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'head_up' })).body.ok, true);++ assert.deepStrictEqual(seen.slice(n2, n2 + 2).map((x) => x.body.value), [{ name: 'head', value: 1 }, { name: 'head_up', state: 'down' }], 'a hold button is remembered');++ await new Promise((r) => setTimeout(r, 700));++ const n3 = seen.length;++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'button:ir_light' })).body.ok, true, 'any other button by name');++ assert.deepStrictEqual(seen.slice(n3).map((x) => x.body.value), [{ name: 'ir_light' }]);++ await pause();++ const missing = await hw({ from_user_id: viewer, command: 'turn_right' });++ assert.deepStrictEqual([missing.body.reason, missing.body.message], ['bot.command_not_allowed', 'This robot does not do that.'], 'no such button: the first refusal stands');++ await pause();++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'button:Not A Button!' })).status, 400, 'a button name is a plain word');++ await pause();++ const n4 = seen.length;++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'say:hi' })).body.reason, 'bot.command_not_allowed');++ assert.strictEqual(seen.length - n4, 1, 'say is never pressed as a button');await d.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);} finally {stub.close();
@@ -108,9 +108,9 @@ Live's own principal (`live`) holds grants to call:
@@ -108,9 +108,9 @@ 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`).
-**Robots are OpenVibe.Bot's (plan T15).** Live has no control socket, hardware API or ONVIF code. A bound channel's chat commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) reach `POST /internal/chat-effects/hardware` from OpenVibe.Chat and go to Bot as the viewer who typed them (`server/bot/bot-client.js`): Bot's roles, queue, cooldowns and e-stop decide.
+**Robots are OpenVibe.Bot's (plan T15).** Live has no control socket, hardware API or ONVIF code. A bound channel's chat commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) reach `POST /internal/chat-effects/hardware` from OpenVibe.Chat and go to Bot as the viewer who typed them (`server/bot/bot-client.js`): Bot's roles, queue, cooldowns and e-stop decide. On a robot made of buttons (Bot's generic relay or a local profile), which has no drive or actuator, the chat word is pressed as its button of the same name (`forward`, `turn_left`, `head_up` …; a hold button is pressed for the 600 ms pulse and released), and `button:<name>` (Chat's `!<word>` that is not a channel sound) presses any other button.
-**Bot panel embed (`LIVE_BOT_EMBED`, off by default; on in production).** 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.
+**Bot panel embed (`LIVE_BOT_EMBED`, off by default; on in production).** 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. The easier way to bind is from Bot: the robot page's **Put the buttons under my OpenVibe.Live stream** turns its embedding on and opens the owner's channel with `?bind_robot=rob_…`, where they confirm (`public/js/bot-bind.js`). The panel's Drive asks the page around it for a ticket (`postMessage` `ov-bot-ticket-request`, answered only to that frame on Bot's origin): `POST /api/streams/channel/:username/bot/ticket` `{ robot_id }` (signed in; the channel's own robot only; limited as `live.bot.ticket`) gets one from Bot as the viewer (`controlTicket`, `bot.robot.control`), so a viewer signed in here drives with no popup or second sign-in; Bot's refusal is said in the panel. When the robot's owner has not turned the panel on, Bot's framed refusal tells the page, which hides the box from viewers (the owner keeps it, with the link to the switch).
| Variable | Default | Meaning |
| Variable | Default | Meaning |
| --- | --- | --- |
| --- | --- | --- |
@@ -88,7 +88,8 @@
@@ -88,7 +88,8 @@
<!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
<!-- 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">
<div class="dash-card" id="dash-card-bot-embed" style="display:none">
<h3><i class="fa-solid fa-robot"></i> Your robot</h3>
<h3><i class="fa-solid fa-robot"></i> Your robot</h3>
- <p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera) and put its id here. Its live panel shows under your stream, and chat drives it with <code>!forward</code>, <code>!backward</code>, <code>!left</code>, <code>!right</code>, <code>!headup</code>, <code>!liftup</code> and <code>!say</code>. Who may drive is the robot's setting on OpenVibe.Bot: only you, people you invite, or anyone signed in taking turns.</p>
+ <p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>
+ <p class="muted" style="font-size:0.85rem">Viewers signed in here press Drive on the panel. Chat drives it with <code>!forward</code>, <code>!backward</code>, <code>!left</code>, <code>!right</code>, <code>!headup</code>, <code>!liftup</code> and <code>!say</code>; on a robot made of buttons, <code>!</code> and a button's name presses it. Who may drive is the robot's setting on OpenVibe.Bot (beside that button): only you and people you add, people you invite, or anyone signed in taking turns.</p>
<div class="settings-row">
<div class="settings-row">
<label class="settings-row-label" for="dash-bot-robot-id">Robot id</label>
<label class="settings-row-label" for="dash-bot-robot-id">Robot id</label>
<div class="settings-row-content">
<div class="settings-row-content">
@@ -762,7 +762,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -762,7 +762,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_applyChannelLanguage(data.language, ch, liveStreams.length > 0);
_applyChannelLanguage(data.language, ch, liveStreams.length > 0);
_resetChannelTabs(ch);
_resetChannelTabs(ch);
_applyChannelTabMeta(data);
_applyChannelTabMeta(data);
- _applyBotEmbed(data.bot_embed);
+ _applyBotEmbed(data.bot_embed, username);
_offerBotBind(username);
_offerBotBind(username);
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
_applyChannelHashTab(); // deep-link: #ai-timeline / #about / #videos … opens that tab
// Reveal the Media Request tab if the streamer has it enabled (non-blocking).
// Reveal the Media Request tab if the streamer has it enabled (non-blocking).
@@ -2305,13 +2305,35 @@ async function loadChannelAiTimeline(username) {
@@ -2305,13 +2305,35 @@ async function loadChannelAiTimeline(username) {
// The channel's OpenVibe.Bot robot panel (roadmap T15 R9). `bot_embed` is only in the channel JSON with
// 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
// 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.
// the flag off this does nothing. The frame goes when the route ends, closing its socket to Bot.
-function _applyBotEmbed(botEmbed) {
+// The panel's Drive asks this page for a ticket: a viewer signed in here drives with no popup or second sign-in
+// (POST /api/streams/channel/:username/bot/ticket; Bot decides who may). When the robot's owner has not turned the
+// panel on, viewers do not see an empty box; the owner keeps the frame, which links to the switch on openvibe.bot.
+let _botEmbedStop = null;
+function _applyBotEmbed(botEmbed, username) {
const box = document.getElementById('ch-bot-container');
const box = document.getElementById('ch-bot-container');
if (!box) return;
if (!box) return;
const show = () => {
const show = () => {
if (typeof BotEmbed === 'undefined') return;
if (typeof BotEmbed === 'undefined') return;
- BotEmbed.mount(box, botEmbed);
- if (window.ov) ov.scope().onDispose(() => BotEmbed.mount(box, null));
+ const frame = BotEmbed.mount(box, botEmbed);
+ if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }
+ if (frame && username && typeof BotEmbed.listen === 'function') {
+ const robotId = botEmbed.robot_id;
+ _botEmbedStop = BotEmbed.listen({
+ frame, botEmbed,
+ requestTicket: async () => {
+ if (!currentUser) return { message: 'Sign in on OpenVibe.Live to drive the robot.' };
+ try {
+ return await api(`/streams/channel/${encodeURIComponent(username)}/bot/ticket`, { method: 'POST', body: { robot_id: robotId } });
+ } catch (e) { return { message: (e && e.message) || 'You cannot drive this robot from here.' }; }
+ },
+ onRefused: () => {
+ if (currentUser && currentUser.username === username) return;
+ if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; }
+ BotEmbed.mount(box, null);
+ },
+ });
+ }
+ if (window.ov) ov.scope().onDispose(() => { if (_botEmbedStop) { _botEmbedStop(); _botEmbedStop = null; } BotEmbed.mount(box, null); });
};
};
if (typeof BotEmbed !== 'undefined') return show();
if (typeof BotEmbed !== 'undefined') return show();
if (!(botEmbed && botEmbed.url) || !window.ov) return;
if (!(botEmbed && botEmbed.url) || !window.ov) return;
@@ -2329,7 +2351,7 @@ function _offerBotBind(username) {
@@ -2329,7 +2351,7 @@ function _offerBotBind(username) {
ov.load('botBind').then(() => {
ov.load('botBind').then(() => {
if (!ov.isCurrent(gen) || typeof BotBind === 'undefined') return;
if (!ov.isCurrent(gen) || typeof BotBind === 'undefined') return;
const robotId = BotBind.robotFrom(location.search);
const robotId = BotBind.robotFrom(location.search);
- if (robotId) BotBind.offer({ username, robotId, api, toast, onBound: _applyBotEmbed });
+ if (robotId) BotBind.offer({ username, robotId, api, toast, onBound: (be) => _applyBotEmbed(be, username) });
}, (e) => console.warn('[BotBind] load failed', e));
}, (e) => console.warn('[BotBind] load failed', e));
}
}
@@ -8,6 +8,13 @@
@@ -8,6 +8,13 @@
mount(container, botEmbed) → puts one iframe in container and unhides its section; removes it and hides
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
the section when there is nothing to show. Same url twice keeps the frame
(an operator's session survives a channel refresh).
(an operator's session survives a channel refresh).
+ listen({ frame, botEmbed, requestTicket, onRefused }) → stop(): answers the framed panel (OpenVibe.Bot
+ public/panel.js and refused.js), only from that frame and Bot's origin, for
+ this robot only:
+ ov-bot-ticket-request → await requestTicket() ({ ticket } or { message }) →
+ ov-bot-ticket { ticket } or ov-bot-ticket-none { message }: Drive without a
+ popup or a second sign-in on openvibe.bot
+ ov-bot-embed-refused → onRefused(): the owner has not turned the panel on
Plain script for the browser (window.BotEmbed) and require() for the tests.
Plain script for the browser (window.BotEmbed) and require() for the tests.
*/
*/
@@ -69,5 +76,33 @@
@@ -69,5 +76,33 @@
return frame;
return frame;
}
}
- return { iframeAttrs, mount };
+ function listen({ frame, botEmbed, requestTicket, onRefused = () => {}, win = typeof window !== 'undefined' ? window : null }) {
+ const src = embedSrc(botEmbed);
+ if (!frame || !src || !win || typeof requestTicket !== 'function') return () => {};
+ const origin = new URL(src).origin;
+ const robotId = botEmbed.robot_id;
+ let asking = false;
+ const reply = (msg) => {
+ try { if (frame.contentWindow) frame.contentWindow.postMessage(Object.assign({ robot_id: robotId }, msg), origin); } catch { /* the frame went away */ }
+ };
+ const onMessage = async (e) => {
+ if (e.origin !== origin || !frame.contentWindow || e.source !== frame.contentWindow) return;
+ const d = e.data || {};
+ if (!d || d.robot_id !== robotId) return;
+ if (d.type === 'ov-bot-embed-refused') { onRefused(); return; }
+ if (d.type !== 'ov-bot-ticket-request' || asking) return;
+ asking = true;
+ try {
+ const out = await requestTicket();
+ if (out && typeof out.ticket === 'string') reply({ type: 'ov-bot-ticket', ticket: out.ticket });
+ else reply({ type: 'ov-bot-ticket-none', message: String((out && out.message) || 'You cannot drive this robot from here.').slice(0, 200) });
+ } catch (err) {
+ reply({ type: 'ov-bot-ticket-none', message: String((err && err.message) || 'You cannot drive this robot from here.').slice(0, 200) });
+ } finally { asking = false; }
+ };
+ win.addEventListener('message', onMessage);
+ return () => win.removeEventListener('message', onMessage);
+ }
+
+ return { iframeAttrs, mount, listen };
});
});
@@ -5,6 +5,10 @@
@@ -5,6 +5,10 @@
*
*
* command(robotId, subject, { id, kind, value, ms }) → Bot's command_result ({ result, … }); a refusal throws
* command(robotId, subject, { id, kind, value, ms }) → Bot's command_result ({ result, … }); a refusal throws
* a BotRefusal with Bot's code and reason
* a BotRefusal with Bot's code and reason
+ * controlTicket(robotId, subject) → { ticket, robot_id, role, expires_in }: a one-time Drive
+ * ticket for the robot's panel framed on the channel page,
+ * so a viewer signed in here drives with no popup or second
+ * sign-in (Bot's POST /api/v1/robots/:id/control-ticket)
*
*
* The token is Live's client-credentials token for audience openvibe.bot (Network's grant to `live`:
* The token is Live's client-credentials token for audience openvibe.bot (Network's grant to `live`:
* bot.robot.control); X-OV-Subject names the viewer, so Bot's roles, queue, cooldowns and e-stop apply to them.
* bot.robot.control); X-OV-Subject names the viewer, so Bot's roles, queue, cooldowns and e-stop apply to them.
@@ -44,16 +48,17 @@ function serviceTokens() {
@@ -44,16 +48,17 @@ function serviceTokens() {
return tokens;
return tokens;
}
}
-async function command(robotId, subject, { id, kind, value, ms }) {
+/** POST to a robot's route as the viewer: Bot's answer, or a BotRefusal / BotUnavailable. */
+async function post(robotId, route, subject, body) {
if (drill()) throw new BotUnavailable('restore drill: nothing is sent');
if (drill()) throw new BotUnavailable('restore drill: nothing is sent');
const client = serviceTokens();
const client = serviceTokens();
if (!client) throw new BotUnavailable('OV_OAUTH_CLIENT_SECRET is not set');
if (!client) throw new BotUnavailable('OV_OAUTH_CLIENT_SECRET is not set');
let res;
let res;
try {
try {
- res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/commands`, {
+ res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/${route}`, {
method: 'POST',
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${await client.getToken()}`, 'X-OV-Subject': subject },
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${await client.getToken()}`, 'X-OV-Subject': subject },
- body: JSON.stringify({ id, kind, value, ms }),
+ body: JSON.stringify(body),
signal: AbortSignal.timeout(TIMEOUT_MS),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
});
} catch (err) { note(err); throw new BotUnavailable(err.message); }
} catch (err) { note(err); throw new BotUnavailable(err.message); }
@@ -65,4 +70,7 @@ async function command(robotId, subject, { id, kind, value, ms }) {
@@ -65,4 +70,7 @@ async function command(robotId, subject, { id, kind, value, ms }) {
return out;
return out;
}
}
-module.exports = { command, BotRefusal, BotUnavailable, AUDIENCE };
+const command = (robotId, subject, { id, kind, value, ms }) => post(robotId, 'commands', subject, { id, kind, value, ms });
+const controlTicket = (robotId, subject) => post(robotId, 'control-ticket', subject, {});
+
+module.exports = { command, controlTicket, BotRefusal, BotUnavailable, AUDIENCE };
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
/**
/**
* Binding a channel to an OpenVibe.Bot robot (roadmap T15 R9), mounted under /api/streams.
* 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
+ * PUT /api/streams/channel/:username/bot { robot_id: 'rob_…' | null } — the channel owner only
+ * POST /api/streams/channel/:username/bot/ticket { robot_id } — any signed-in viewer: a Drive ticket for the
+ * channel's robot, asked by its panel framed on the page (public/js/bot-embed.js listen), so a viewer signed in
+ * here drives with no popup or second sign-in on openvibe.bot. Bot decides who may (a member, or anyone on a
+ * robot that takes turns): a refusal answers 403 with its words.
*
*
- * With LIVE_BOT_EMBED off the route answers 404 as if it did not exist. Binding does not call Bot: the
+ * With LIVE_BOT_EMBED off the routes answer 404 as if they 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).
* robot owner's embed_public toggle there decides what the embed shows (server/bot/embed.js).
*/
*/
'use strict';
'use strict';
@@ -38,4 +42,35 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
@@ -38,4 +42,35 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
}
}
});
});
+// What Bot's refusal means, said to the viewer in the panel (Bot's codes, OpenVibe.Bot docs/protocol.md §3).
+const TICKET_REFUSALS = {
+ 'bot.not_an_operator': 'The robot\'s owner decides who may drive it.',
+ 'bot.robot_not_found': 'This channel\'s robot no longer exists.',
+};
+
+router.post('/channel/:username/bot/ticket', (req, res, next) => (embed.enabled() ? next() : notFound(res)), requireAuth, async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try {
+ const channel = await db.getChannelByUsername(req.params.username);
+ const robotId = channel && embed.validRobotId(channel.bot_robot_id) ? channel.bot_robot_id : null;
+ // Only the robot this channel shows: the page cannot ask Live for tickets to other robots.
+ if (!robotId || (req.body && req.body.robot_id !== robotId)) return res.status(404).json({ error: 'This channel shows no such robot' });
+ const subject = await require('../auth/identity-sync').subjectOf(req.user.id);
+ if (!subject) return res.status(409).json({ error: 'Sign in again on OpenVibe.Live to drive the robot.' });
+ const bot = require('./bot-client');
+ try {
+ const t = await bot.controlTicket(robotId, subject);
+ return res.json({ ticket: t.ticket, robot_id: robotId, role: t.role || null, expires_in: t.expires_in || null });
+ } catch (err) {
+ if (err instanceof bot.BotRefusal) {
+ return res.status(403).json({ error: TICKET_REFUSALS[err.code] || 'You cannot drive this robot from here.', code: err.code });
+ }
+ return res.status(503).json({ error: 'The robot service did not answer. Try again in a moment.' });
+ }
+ } catch (err) {
+ console.error('[BotEmbed] Ticket error:', err.message);
+ res.status(500).json({ error: 'Could not ask for the controls' });
+ }
+});
+
module.exports = router;
module.exports = router;
@@ -498,6 +498,14 @@ const CHAT_ROBOT_COMMANDS = {
@@ -498,6 +498,14 @@ const CHAT_ROBOT_COMMANDS = {
head_up: { kind: 'actuator', value: { name: 'head', value: 1 } },
head_up: { kind: 'actuator', value: { name: 'head', value: 1 } },
head_down: { kind: 'actuator', value: { name: 'head', value: -1 } },
head_down: { kind: 'actuator', value: { name: 'head', value: -1 } },
};
};
+// A robot made of buttons (OpenVibe.Bot's generic relay, or a local profile: Maticus's Cozmo Hobo, 2026-10-26, "chat
+// commands do nothing on my button robot") has no drive or actuator, and Bot refuses those as bot.command_not_allowed
+// before anything is spent. The chat word is then pressed as the robot's button of the same name (forward, turn_left,
+// head_up …), and `button:<name>` (OpenVibe.Chat's !<word> that is not a channel sound) presses any other button. A
+// hold button needs a press and a release: Bot says so (bot.invalid_input) to a plain press, the answer is remembered
+// per robot, and the release follows the pulse.
+const BUTTON_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
+const buttonHold = new Map(); // `${robot}:${name}` → true for a hold button
const CHAT_ROBOT_MS = 600;
const CHAT_ROBOT_MS = 600;
const HARDWARE_COOLDOWN_MS = 250;
const HARDWARE_COOLDOWN_MS = 250;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -514,10 +522,31 @@ const ROBOT_REFUSALS = {
@@ -514,10 +522,31 @@ const ROBOT_REFUSALS = {
'bot.unknown_actuator': 'This robot does not do that.',
'bot.unknown_actuator': 'This robot does not do that.',
'bot.robot_not_found': 'This channel\'s robot no longer exists.',
'bot.robot_not_found': 'This channel\'s robot no longer exists.',
};
};
+async function pressButton(bot, robotId, subject, name) {
+ const key = `${robotId}:${name}`;
+ const press = (hold) => bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, kind: 'button', value: hold ? { name, state: 'down' } : { name }, ...(hold ? { ms: CHAT_ROBOT_MS } : {}) });
+ let hold = buttonHold.get(key) === true;
+ let out;
+ try { out = await press(hold); } catch (err) {
+ if (hold || !(err instanceof bot.BotRefusal) || err.code !== 'bot.invalid_input') throw err;
+ hold = true;
+ buttonHold.set(key, true);
+ if (buttonHold.size > 2000) buttonHold.delete(buttonHold.keys().next().value);
+ out = await press(true);
+ }
+ if (hold) {
+ // Bot stops it at the deadline anyway; the release ends it on time and frees the button for the next press.
+ setTimeout(() => bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, kind: 'button', value: { name, state: 'up' } }).catch(() => {}), CHAT_ROBOT_MS).unref();
+ }
+ return out;
+}
+
effectsRouter.post('/hardware', async (req, res) => {
effectsRouter.post('/hardware', async (req, res) => {
const command = String(req.body?.command || '');
const command = String(req.body?.command || '');
const say = /^say:([\s\S]{1,200})$/.exec(command);
const say = /^say:([\s\S]{1,200})$/.exec(command);
- const mapped = say ? { kind: 'say', value: { text: say[1] } } : CHAT_ROBOT_COMMANDS[command];
+ const button = /^button:(.+)$/.exec(command);
+ if (button && !BUTTON_NAME_RE.test(button[1])) return fail(res, 400, 'unknown command');
+ const mapped = say ? { kind: 'say', value: { text: say[1] } } : button ? { kind: 'button', name: button[1] } : CHAT_ROBOT_COMMANDS[command];
if (!mapped) return fail(res, 400, 'unknown command');
if (!mapped) return fail(res, 400, 'unknown command');
const user = await db.getUserById(int(req.body.streamer_user_id));
const user = await db.getUserById(int(req.body.streamer_user_id));
if (!user) return res.json({ ok: false, reason: 'no_user' });
if (!user) return res.json({ ok: false, reason: 'no_user' });
@@ -532,8 +561,19 @@ effectsRouter.post('/hardware', async (req, res) => {
@@ -532,8 +561,19 @@ effectsRouter.post('/hardware', async (req, res) => {
hardwareLast.set(key, now);
hardwareLast.set(key, now);
if (hardwareLast.size > 5000) hardwareLast.delete(hardwareLast.keys().next().value);
if (hardwareLast.size > 5000) hardwareLast.delete(hardwareLast.keys().next().value);
const bot = require('../bot/bot-client');
const bot = require('../bot/bot-client');
+ const robotId = channel.bot_robot_id;
try {
try {
- const out = await bot.command(channel.bot_robot_id, subject, { id: `chat-${require('crypto').randomUUID()}`, ...mapped, ...(mapped.kind === 'say' ? {} : { ms: CHAT_ROBOT_MS }) });
+ let out;
+ if (mapped.kind === 'button') out = await pressButton(bot, robotId, subject, mapped.name);
+ else {
+ try {
+ out = await bot.command(robotId, subject, { id: `chat-${require('crypto').randomUUID()}`, ...mapped, ...(mapped.kind === 'say' ? {} : { ms: CHAT_ROBOT_MS }) });
+ } catch (err) {
+ if (!(err instanceof bot.BotRefusal) || err.code !== 'bot.command_not_allowed' || mapped.kind === 'say') throw err;
+ // No drive or actuator here: its button of the same name, if it has one; else the first refusal stands.
+ out = await pressButton(bot, robotId, subject, command).catch((again) => { throw again instanceof bot.BotRefusal && again.code === 'bot.command_not_allowed' ? err : again; });
+ }
+ }
return res.json({ ok: true, result: out.result || null });
return res.json({ ok: true, result: out.result || null });
} catch (err) {
} catch (err) {
if (err instanceof bot.BotRefusal) {
if (err instanceof bot.BotRefusal) {
@@ -41,6 +41,8 @@ const ROUTES = [
@@ -41,6 +41,8 @@ const ROUTES = [
['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.
// 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.bot.bind', /^PUT$/, /^\/streams\/channel\/[^/]+\/bot$/, { minute: 10, hour: 60 }],
+ // A Drive ticket for the channel's robot panel: one per press of Drive (a reconnect gets its next one from Bot).
+ ['live.bot.ticket', /^POST$/, /^\/streams\/channel\/[^/]+\/bot\/ticket$/, { minute: 20, hour: 300 }],
['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 }],
@@ -7,6 +7,10 @@
@@ -7,6 +7,10 @@
* the channel GET carries the url the iframe uses; flag off → no bot_embed, and the page code
* 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.
* (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.
* (c) The dashboard's binding card (dashboard.js) shows only with the flag on, saves, unbinds and shows server errors.
+ * (d) Drive from the framed panel (Maticus, 2026-10-26: driving took a popup and a second sign-in): listen() answers
+ * the panel's ticket request and refusal only from that frame and Bot's origin, for this robot; and
+ * POST /api/streams/channel/:username/bot/ticket asks Bot (a stub) for a ticket as the viewer, for the channel's
+ * own robot only, saying Bot's refusal in plain words.
*
*
* node test/bot-embed-page.test.js
* node test/bot-embed-page.test.js
*/
*/
@@ -124,8 +128,9 @@ async function check(name, fn) {
@@ -124,8 +128,9 @@ async function check(name, fn) {
assert.match(frag, /<div id="ch-bot-container"><\/div>/);
assert.match(frag, /<div id="ch-bot-container"><\/div>/);
assert.ok(!/<iframe/i.test(frag), 'no iframe in the markup');
assert.ok(!/<iframe/i.test(frag), 'no iframe in the markup');
const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
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);'));
+ assert.strictEqual((app.match(/_applyBotEmbed\(/g) || []).length, 3, 'defined once, called for the channel and after binding');
+ assert.ok(app.includes('_applyBotEmbed(data.bot_embed, username);'));
+ assert.ok(app.includes('onBound: (be) => _applyBotEmbed(be, username)'));
});
});
// ── (b) end to end through the real channel routes ──
// ── (b) end to end through the real channel routes ──
@@ -154,14 +159,14 @@ async function check(name, fn) {
@@ -154,14 +159,14 @@ async function check(name, fn) {
app.use('/api/streams', require('../server/streaming/routes'));
app.use('/api/streams', require('../server/streaming/routes'));
const server = http.createServer(app).listen(0, '127.0.0.1');
const server = http.createServer(app).listen(0, '127.0.0.1');
if (!server.listening) await new Promise((r) => server.once('listening', r));
if (!server.listening) await new Promise((r) => server.once('listening', r));
- const call = (method, p, body) => new Promise((resolve, reject) => {
+ const call = (method, p, body, user = '1') => new Promise((resolve, reject) => {
const data = body === undefined ? null : JSON.stringify(body);
const data = body === undefined ? null : JSON.stringify(body);
- const headers = { 'x-test-user': '1' };
+ const headers = user ? { 'x-test-user': user } : {};
if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }
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) => {
const req = http.request({ host: '127.0.0.1', port: server.address().port, method, path: p, headers }, (res) => {
let text = '';
let text = '';
res.on('data', (c) => { text += c; });
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 }); });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, body: json, text, headers: res.headers }); });
});
});
req.on('error', reject);
req.on('error', reject);
if (data) req.write(data);
if (data) req.write(data);
@@ -173,7 +178,7 @@ async function check(name, fn) {
@@ -173,7 +178,7 @@ async function check(name, fn) {
const page = () => {
const page = () => {
const dom = fakeDom();
const dom = fakeDom();
const loads = [];
const loads = [];
- const env = { BotEmbed: undefined, document: { getElementById: (id) => (id === 'ch-bot-container' ? dom.container : null) } };
+ const env = { BotEmbed: undefined, _botEmbedStop: null, currentUser: null, api: async () => ({}), document: { getElementById: (id) => (id === 'ch-bot-container' ? dom.container : null) } };
const disposers = [];
const disposers = [];
env.window = { ov: true };
env.window = { ov: true };
env.ov = {
env.ov = {
@@ -186,9 +191,9 @@ async function check(name, fn) {
@@ -186,9 +191,9 @@ async function check(name, fn) {
// app-channel.js's _applyBotEmbed run on its own; `with` lets it see the fake document/ov/BotEmbed globals.
// app-channel.js's _applyBotEmbed run on its own; `with` lets it see the fake document/ov/BotEmbed globals.
const runApply = (p, botEmbed) => {
const runApply = (p, botEmbed) => {
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
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];
+ const fn = src.match(/\nfunction _applyBotEmbed\(botEmbed, username\) \{[\s\S]*?\n\}\n/)[0];
// eslint-disable-next-line no-new-func
// eslint-disable-next-line no-new-func
- new Function('env', 'botEmbed', `with (env) { ${fn}; _applyBotEmbed(botEmbed); }`)(p.env, botEmbed);
+ new Function('env', 'botEmbed', `with (env) { ${fn}; _applyBotEmbed(botEmbed, 'rover'); }`)(p.env, botEmbed);
return new Promise((r) => setImmediate(r));
return new Promise((r) => setImmediate(r));
};
};
@@ -283,6 +288,83 @@ async function check(name, fn) {
@@ -283,6 +288,83 @@ async function check(name, fn) {
assert.strictEqual(d.els['dash-bot-robot-status'].textContent, 'Bound to rob_sim123');
assert.strictEqual(d.els['dash-bot-robot-status'].textContent, 'Bound to rob_sim123');
});
});
+ await check('listen(): the panel\'s ticket request is answered to Bot\'s origin; refusals and strangers are not', async () => {
+ const listeners = new Set();
+ const win = { addEventListener: (t, f) => listeners.add(f), removeEventListener: (t, f) => listeners.delete(f) };
+ const posted = [];
+ const panel = { postMessage: (msg, origin) => posted.push([msg, origin]) };
+ const frame = { contentWindow: panel };
+ let answer = { ticket: 'T'.repeat(43) };
+ let asked = 0;
+ let refused = 0;
+ const stop = BotEmbed.listen({ frame, botEmbed: SIM, win, requestTicket: async () => { asked++; return answer; }, onRefused: () => { refused++; } });
+ const send = async (data, { origin = 'https://openvibe.bot', source = panel } = {}) => {
+ for (const f of [...listeners]) await f({ data, origin, source });
+ };
+ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' }, { origin: 'https://evil.example' });
+ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' }, { source: {} });
+ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_other' });
+ assert.strictEqual(asked, 0, 'another origin, another window or another robot is ignored');
+ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' });
+ assert.deepStrictEqual(posted.at(-1), [{ robot_id: 'rob_sim123', type: 'ov-bot-ticket', ticket: 'T'.repeat(43) }, 'https://openvibe.bot']);
+ answer = { message: 'Sign in on OpenVibe.Live to drive the robot.' };
+ await send({ type: 'ov-bot-ticket-request', robot_id: 'rob_sim123' });
+ assert.deepStrictEqual(posted.at(-1)[0], { robot_id: 'rob_sim123', type: 'ov-bot-ticket-none', message: 'Sign in on OpenVibe.Live to drive the robot.' });
+ await send({ type: 'ov-bot-embed-refused', robot_id: 'rob_sim123' }, { origin: 'https://evil.example' });
+ assert.strictEqual(refused, 0);
+ await send({ type: 'ov-bot-embed-refused', robot_id: 'rob_sim123' });
+ assert.strictEqual(refused, 1);
+ stop();
+ assert.strictEqual(listeners.size, 0, 'stop() removes the listener');
+ assert.strictEqual(typeof BotEmbed.listen({ frame, botEmbed: { url: 'javascript:alert(1)' }, win, requestTicket: async () => ({}) }), 'function');
+ assert.strictEqual(listeners.size, 0, 'nothing listens for a url it would not frame');
+ });
+
+ await check('POST …/bot/ticket: a Drive ticket from Bot as the viewer, for the channel\'s own robot only', async () => {
+ const seen = [];
+ let reply = { status: 200, body: { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 } };
+ const stub = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));
+ seen.push({ url: req.url, method: req.method, auth: req.headers.authorization, subject: req.headers['x-ov-subject'] });
+ res.statusCode = reply.status;
+ res.end(JSON.stringify(reply.body));
+ });
+ });
+ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
+ const stubUrl = `http://127.0.0.1:${stub.address().port}`;
+ const ENV = ['OV_OAUTH_CLIENT_SECRET', 'OV_NETWORK_INTERNAL_URL', 'LIVE_BOT_INTERNAL_URL'];
+ const before = Object.fromEntries(ENV.map((k) => [k, process.env[k]]));
+ Object.assign(process.env, { OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_NETWORK_INTERNAL_URL: stubUrl, LIVE_BOT_INTERNAL_URL: stubUrl });
+ const ticket = (body, user) => call('POST', '/api/streams/channel/rover/bot/ticket', body, user);
+ try {
+ delete process.env.LIVE_BOT_EMBED;
+ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 404, 'flag off: no such route');
+ process.env.LIVE_BOT_EMBED = '1';
+ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' }, null)).status, 401, 'signed in only');
+ assert.strictEqual((await ticket({ robot_id: 'rob_other1' })).status, 404, 'not the channel\'s robot');
+ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 409, 'no Network subject linked');
+ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '901', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run();
+ const ok = await ticket({ robot_id: 'rob_sim123' });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.deepStrictEqual(ok.body, { ticket: 'K'.repeat(43), robot_id: 'rob_sim123', role: 'queue', expires_in: 120 });
+ assert.strictEqual(ok.headers['cache-control'], 'no-store');
+ assert.deepStrictEqual(seen.at(-1), { url: '/api/v1/robots/rob_sim123/control-ticket', method: 'POST', auth: 'Bearer live-bot-token', subject: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' });
+ reply = { status: 403, body: { code: 'bot.not_an_operator', detail: 'the owner decides who may drive this robot' } };
+ const no = await ticket({ robot_id: 'rob_sim123' });
+ assert.deepStrictEqual([no.status, no.body.code, no.body.error], [403, 'bot.not_an_operator', 'The robot\'s owner decides who may drive it.']);
+ reply = { status: 503, body: {} };
+ assert.strictEqual((await ticket({ robot_id: 'rob_sim123' })).status, 503, 'Bot down is said so');
+ } finally {
+ stub.close();
+ await raw.prepare("DELETE FROM linked_accounts WHERE user_id = 1 AND service_user_id = '901'").run();
+ for (const k of ENV) { if (before[k] === undefined) delete process.env[k]; else process.env[k] = before[k]; }
+ }
+ });
+
await check('dashboard, flag off: the binding card stays hidden', async () => {
await check('dashboard, flag off: the binding card stays hidden', async () => {
delete process.env.LIVE_BOT_EMBED;
delete process.env.LIVE_BOT_EMBED;
const d = dashboard();
const d = dashboard();
@@ -1045,9 +1045,11 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -1045,9 +1045,11 @@ const waitFor = async (pred, what, ms = 3000) => {
req.on('end', () => {
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));
if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));
- seen.push({ url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body: JSON.parse(raw || '{}') });
- res.statusCode = answer.status;
- return res.end(JSON.stringify(answer.body));
+ const body = JSON.parse(raw || '{}');
+ seen.push({ url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body });
+ const a = typeof answer === 'function' ? answer(body) : answer;
+ res.statusCode = a.status;
+ return res.end(JSON.stringify(a.body));
});
});
});
});
await new Promise((r) => stub.listen(0, '127.0.0.1', r));
await new Promise((r) => stub.listen(0, '127.0.0.1', r));
@@ -1085,6 +1087,44 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -1085,6 +1087,44 @@ const waitFor = async (pred, what, ms = 3000) => {
await pause();
await pause();
assert.strictEqual((await hw({ from_user_id: viewer })).body.reason, 'robot_unavailable', 'Bot down is said so');
assert.strictEqual((await hw({ from_user_id: viewer })).body.reason, 'robot_unavailable', 'Bot down is said so');
assert.strictEqual((await hw({ from_user_id: viewer, command: 'self_destruct' })).status, 400, 'an unknown command');
assert.strictEqual((await hw({ from_user_id: viewer, command: 'self_destruct' })).status, 400, 'an unknown command');
+ // A robot made of buttons (Maticus's Cozmo Hobo: forward, turn_left, head_up … ir_light): Bot refuses drive
+ // and actuator before anything is spent, and the chat word is pressed as its button of that name. A hold
+ // button (Bot: "a hold button needs down or up") is pressed down for the pulse and released after it.
+ const NOT_ALLOWED = { status: 422, body: { code: 'bot.command_not_allowed', detail: 'not on this robot' } };
+ const BUTTONS = ['forward', 'turn_left', 'head_up', 'ir_light'];
+ answer = (b) => {
+ if (b.kind !== 'button' || !BUTTONS.includes(b.value.name)) return NOT_ALLOWED;
+ if (b.value.name === 'head_up' && !b.value.state) return { status: 422, body: { code: 'bot.invalid_input', detail: 'a hold button needs down or up' } };
+ return { status: 200, body: { result: 'ack' } };
+ };
+ await pause();
+ const n0 = seen.length;
+ const pressed = await hw({ from_user_id: viewer, command: 'forward' });
+ assert.strictEqual(pressed.body.ok, true, JSON.stringify(pressed.body));
+ assert.deepStrictEqual(seen.slice(n0).map((x) => [x.body.kind, x.body.value, x.body.ms]), [['drive', { throttle: 0.6 }, 600], ['button', { name: 'forward' }, undefined]]);
+ await pause();
+ const n1 = seen.length;
+ assert.strictEqual((await hw({ from_user_id: viewer, command: 'head_up' })).body.ok, true);
+ await new Promise((r) => setTimeout(r, 700));
+ assert.deepStrictEqual(seen.slice(n1).map((x) => [x.body.kind, x.body.value, x.body.ms]), [
+ ['actuator', { name: 'head', value: 1 }, 600], ['button', { name: 'head_up' }, undefined],
+ ['button', { name: 'head_up', state: 'down' }, 600], ['button', { name: 'head_up', state: 'up' }, undefined]]);
+ const n2 = seen.length;
+ assert.strictEqual((await hw({ from_user_id: viewer, command: 'head_up' })).body.ok, true);
+ assert.deepStrictEqual(seen.slice(n2, n2 + 2).map((x) => x.body.value), [{ name: 'head', value: 1 }, { name: 'head_up', state: 'down' }], 'a hold button is remembered');
+ await new Promise((r) => setTimeout(r, 700));
+ const n3 = seen.length;
+ assert.strictEqual((await hw({ from_user_id: viewer, command: 'button:ir_light' })).body.ok, true, 'any other button by name');
+ assert.deepStrictEqual(seen.slice(n3).map((x) => x.body.value), [{ name: 'ir_light' }]);
+ await pause();
+ const missing = await hw({ from_user_id: viewer, command: 'turn_right' });
+ assert.deepStrictEqual([missing.body.reason, missing.body.message], ['bot.command_not_allowed', 'This robot does not do that.'], 'no such button: the first refusal stands');
+ await pause();
+ assert.strictEqual((await hw({ from_user_id: viewer, command: 'button:Not A Button!' })).status, 400, 'a button name is a plain word');
+ await pause();
+ const n4 = seen.length;
+ assert.strictEqual((await hw({ from_user_id: viewer, command: 'say:hi' })).body.reason, 'bot.command_not_allowed');
+ assert.strictEqual(seen.length - n4, 1, 'say is never pressed as a button');
await d.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);
await d.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);
} finally {
} finally {
stub.close();
stub.close();