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)
Changes
@@@ -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; }@@@ -155,6 +155,11 @@"/js/bot-embed.js"]},++ "botBind": {++ "js": [++ "/js/bot-bind.js"++ ]++ },"homeTour": {"js": ["/js/home-tour.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.nnew file mode 100644@@@ -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 };++});nnew file mode 100644@@@ -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');
@@ -1497,3 +1497,10 @@
@@ -1497,3 +1497,10 @@
.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.elsewhere-list .btn { font-size: 0.85rem; }
.elsewhere-list .btn { font-size: 0.85rem; }
.elsewhere-note { font-size: 0.75rem; margin-top: 6px; }
.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; }
@@ -155,6 +155,11 @@
@@ -155,6 +155,11 @@
"/js/bot-embed.js"
"/js/bot-embed.js"
]
]
},
},
+ "botBind": {
+ "js": [
+ "/js/bot-bind.js"
+ ]
+ },
"homeTour": {
"homeTour": {
"js": [
"js": [
"/js/home-tour.js"
"/js/home-tour.js"
@@ -713,6 +713,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -713,6 +713,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_resetChannelTabs(ch);
_resetChannelTabs(ch);
_applyChannelTabMeta(data);
_applyChannelTabMeta(data);
_applyBotEmbed(data.bot_embed);
_applyBotEmbed(data.bot_embed);
+ _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).
_initMediaRequestTab(username);
_initMediaRequestTab(username);
@@ -2265,6 +2266,20 @@ function _applyBotEmbed(botEmbed) {
@@ -2265,6 +2266,20 @@ function _applyBotEmbed(botEmbed) {
ov.load('botEmbed').then(() => { if (ov.isCurrent(gen)) show(); }, (e) => console.warn('[BotEmbed] load failed', e));
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
// 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
// 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.
// streamer keeps them private across all slots with nothing public to show.
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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 };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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');