openvibers/openvibe.live Commits

A robot added on openvibe.bot/link binds to your channel's chat from one link: your own channel asks "Use it in my chat", and its !commands drive it (#244)

OP OpenVibers committed 25 days ago · de798bd03c · 5 files, +154 −0

The one command on a robot's computer (OpenVibe.Bot#140, OpenVibe.Node#50) ends on an approval page that links to the
owner's channel with ?bind_robot=rob_…. There, and only on their own channel, the channel page loads the new "botBind"
feature (public/js/bot-bind.js), which shows a confirm bar above the robot section: Use it in my chat binds the channel
through the existing owner-only PUT /api/streams/channel/:username/bot, mounts the robot's panel, and drops the parameter
from the address; Not now just drops it. The chat's !forward/!left/!say then go to the robot through Bot's own gate (who
may drive, limits, the e-stop). Live had no screen for this binding at all: it was API-only, so no channel was bound.

Built from DOM nodes (no innerHTML); only a robot id the bind route accepts is offered. test/bot-bind.test.js.


Claude-Session: https://claude.ai/code/session_01UgqWFqsrjYdYbusKn5547T

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex ee5f20dc..3a487212 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1497,3 +1497,10 @@
.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.elsewhere-list .btn { font-size: 0.85rem; }
.elsewhere-note { font-size: 0.75rem; margin-top: 6px; }
++
++/* The confirm bar for a robot added on openvibe.bot/link: "Use it in my chat" (public/js/bot-bind.js). */
++.ch-bot-bind { margin: 12px 0; padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
++.ch-bot-bind-text { margin: 0 0 4px; font-weight: 600; color: var(--text-primary); overflow-wrap: anywhere; }
++.ch-bot-bind-text code { font-size: 0.85em; }
++.ch-bot-bind-note { margin: 0 0 10px; font-size: 0.85rem; color: var(--text-secondary); }
++.ch-bot-bind-actions { display: flex; gap: 8px; flex-wrap: wrap; }
ddiff --git a/public/features.json b/public/features.json
iindex 95fddff3..6c87c08d 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -155,6 +155,11 @@
"/js/bot-embed.js"
]
},
++ "botBind": {
++ "js": [
++ "/js/bot-bind.js"
++ ]
++ },
"homeTour": {
"js": [
"/js/home-tour.js"
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 4b3e3306..f7b29d5a 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -713,6 +713,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_resetChannelTabs(ch);
_applyChannelTabMeta(data);
_applyBotEmbed(data.bot_embed);
++ _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).
_initMediaRequestTab(username);
@@@ -2265,6 +2266,20 @@ function _applyBotEmbed(botEmbed) {
ov.load('botEmbed').then(() => { if (ov.isCurrent(gen)) show(); }, (e) => console.warn('[BotEmbed] load failed', e));
}
++// A robot approved on openvibe.bot/link sends its owner here with ?bind_robot=rob_… ("Use it in my OpenVibe.Live chat",
++// OpenVibe.Bot server/claims.js). On their own channel they confirm, and its chat commands drive that robot
++// (public/js/bot-bind.js, feature "botBind", loaded only then).
++function _offerBotBind(username) {
++ if (!/[?&]bind_robot=/.test(location.search) || !window.ov) return;
++ if (!(currentUser && username && currentUser.username === username)) return;
++ const gen = ov.gen();
++ 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 });
++ }, (e) => console.warn('[BotBind] 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-bind.js b/public/js/bot-bind.js
nnew file mode 100644
iindex 00000000..39287621
---- /dev/null
++++ b/public/js/bot-bind.js
@@@ -0,0 +1,92 @@
++/*
++ OpenVibe.Live — bind a robot to your channel's chat from openvibe.bot (feature "botBind").
++
++ A robot approved on openvibe.bot/link (the one command on its computer, OpenVibe.Bot server/claims.js) offers
++ "Use it in my OpenVibe.Live chat", which opens the owner's own channel with ?bind_robot=rob_…. There, and only on
++ their own channel, they confirm and the channel is bound (PUT /api/streams/channel/:username/bot, owner only): its
++ chat commands (!forward, !left, !say …) then drive that robot through Bot's own gate, and the robot's panel shows
++ on the channel. app-channel.js loads this file only when the address carries a robot id.
++
++ robotFrom(search) → the rob_… id in ?bind_robot=, or null
++ bindRequest(username, robotId) → { path, opts } for the api() call
++ offer({ username, robotId, api, toast, onBound }) → shows the confirm bar above the channel's robot section
++
++ Plain script for the browser (window.BotBind) and require() for the tests.
++*/
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.BotBind = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ // The same shape the bind route accepts (server/bot/embed.js validRobotId).
++ const ROBOT_ID = /^rob_[A-Za-z0-9_-]{4,60}$/;
++
++ function robotFrom(search) {
++ let id = null;
++ try { id = new URLSearchParams(String(search || '')).get('bind_robot'); } catch { return null; }
++ return id && ROBOT_ID.test(id) ? id : null;
++ }
++
++ function bindRequest(username, robotId) {
++ return { path: `/streams/channel/${encodeURIComponent(username)}/bot`, opts: { method: 'PUT', body: { robot_id: robotId } } };
++ }
++
++ /** Drop ?bind_robot from the address bar, so a reload does not ask again. */
++ function forget() {
++ try {
++ const u = new URL(location.href);
++ u.searchParams.delete('bind_robot');
++ history.replaceState(history.state, '', u.pathname + (u.search ? u.search : '') + u.hash);
++ } catch { /* the address stays; asking again is harmless */ }
++ }
++
++ function el(tag, cls, text) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
++ }
++
++ function offer({ username, robotId, api, toast = () => {}, onBound = () => {} }) {
++ const section = document.getElementById('ch-bot-section');
++ if (!section || !section.parentNode || document.getElementById('ch-bot-bind')) return null;
++ const bar = el('div', 'ch-bot-bind');
++ bar.id = 'ch-bot-bind';
++ bar.setAttribute('role', 'region');
++ bar.setAttribute('aria-label', 'Drive a robot from your chat');
++ const text = el('p', 'ch-bot-bind-text');
++ text.append('Let your chat drive the robot you just added on OpenVibe.Bot? ', el('code', null, robotId));
++ const note = el('p', 'ch-bot-bind-note',
++ 'Chat commands like !forward, !left and !say go to it, checked by its own rules on openvibe.bot (who may drive, limits, the e-stop). Its panel shows here. You can unbind it any time.');
++ const actions = el('div', 'ch-bot-bind-actions');
++ const yes = el('button', 'btn btn-sm btn-primary', 'Use it in my chat');
++ yes.type = 'button';
++ const no = el('button', 'btn btn-sm btn-ghost', 'Not now');
++ no.type = 'button';
++ actions.append(yes, no);
++ bar.append(text, note, actions);
++ section.parentNode.insertBefore(bar, section);
++ const close = () => { bar.remove(); forget(); };
++ no.addEventListener('click', close);
++ yes.addEventListener('click', async () => {
++ yes.disabled = true;
++ no.disabled = true;
++ const { path, opts } = bindRequest(username, robotId);
++ try {
++ const r = await api(path, opts);
++ close();
++ toast('Your chat drives the robot now. Try !forward in chat.', 'success');
++ onBound(r && r.bot_embed);
++ } catch (e) {
++ yes.disabled = false;
++ no.disabled = false;
++ toast((e && e.message) || 'Could not bind the robot', 'error');
++ }
++ });
++ return bar;
++ }
++
++ return { robotFrom, bindRequest, offer };
++});
ddiff --git a/test/bot-bind.test.js b/test/bot-bind.test.js
nnew file mode 100644
iindex 00000000..ed3edeff
---- /dev/null
++++ b/test/bot-bind.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * Binding a robot to your channel's chat from openvibe.bot (public/js/bot-bind.js, feature "botBind"): a robot approved
++ * on openvibe.bot/link (the one command on its computer) links to the owner's channel with ?bind_robot=rob_…, and the
++ * owner confirms there. Only a robot id the bind route accepts is offered; the request is the existing owner-only
++ * PUT /api/streams/channel/:username/bot; the channel page loads the feature only for that address, on its owner's view.
++ *
++ * node test/bot-bind.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const BotBind = require('../public/js/bot-bind.js');
++const embed = require('../server/bot/embed');
++
++assert.strictEqual(BotBind.robotFrom('?bind_robot=rob_01M588DQNK3B438P6KP48GE693'), 'rob_01M588DQNK3B438P6KP48GE693');
++assert.strictEqual(BotBind.robotFrom('?x=1&bind_robot=rob_01M588DQNK3B438P6KP48GE693&y=2'), 'rob_01M588DQNK3B438P6KP48GE693');
++for (const bad of ['', '?bind_robot=', '?bind_robot=dev_01M5', '?bind_robot=rob_%3Cscript%3E', '?bind_robot=rob_1', null]) {
++ assert.strictEqual(BotBind.robotFrom(bad), null, String(bad));
++}
++// Whatever robotFrom offers, the bind route takes (server/bot/embed.js validRobotId).
++assert.ok(embed.validRobotId(BotBind.robotFrom('?bind_robot=rob_01M588DQNK3B438P6KP48GE693')));
++
++assert.deepStrictEqual(BotBind.bindRequest('goosely', 'rob_01M5'), { path: '/streams/channel/goosely/bot', opts: { method: 'PUT', body: { robot_id: 'rob_01M5' } } });
++assert.strictEqual(BotBind.bindRequest('a b/c', 'rob_x').path, '/streams/channel/a%20b%2Fc/bot', 'the username is one path segment');
++
++const features = JSON.parse(fs.readFileSync(path.join(__dirname, '../public/features.json'), 'utf8'));
++assert.deepStrictEqual(features.features.botBind, { js: ['/js/bot-bind.js'] });
++const channel = fs.readFileSync(path.join(__dirname, '../public/js/app-channel.js'), 'utf8');
++assert.match(channel, /_offerBotBind\(username\);/);
++assert.match(channel, /currentUser\.username === username/, 'offered on the owner\'s own channel only');
++assert.match(channel, /ov\.load\('botBind'\)/, 'loaded only for that address');
++assert.ok(!/innerHTML/.test(fs.readFileSync(path.join(__dirname, '../public/js/bot-bind.js'), 'utf8')), 'built from nodes, never markup');
++
++console.log('bot-bind: ok');