openvibers/openvibe.chat Commits

Chat: robot commands name the viewer (Network subject) so Live can send them to OpenVibe.Bot as that person; Live's refusals are shown in chat (#84)

OP OpenVibers committed 14 Oct 2026 · 91d66f85dc · 4 files, +22 −11

Plan T15 retires Live's robot controls. A channel's chat robot commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) still go to Live (`POST /internal/chat-effects/hardware`). Live now forwards each one to the channel's OpenVibe.Bot robot, as the viewer who typed it (OpenVibe.Live PR, with Network's `bot.robot.control` grant).

Bot takes commands only from people, and judges each against the robot's roles, turn queue, cooldowns and e-stop. So Chat now names the viewer.

**Changes**
- `ctx.effects.hardwareCommand(streamer, command, name, { subject, userId })` sends `from_subject` (the viewer's Network subject, from `_subjectOfUser`) and `from_user_id`. Nothing is sent for a viewer who is not signed in, and Live then answers "Sign in to drive the robot."
- `_hardwareCommand` shows Live's `message` to that viewer alone, for example "It is not your turn at the robot yet." or "This channel has no robot…". A quiet refusal (a cooldown) says nothing. The old fixed "No hardware client connected." is gone with Live's control socket.

**Tests**
- The Live stub answers with the subject and user id it was asked with.
- `ws-protocol` checks that `!forward` from a signed-in viewer reaches Live with their subject and id, and that Live's sentence is shown. It passes (18 checks).

Deploy after Live's change: until then Live ignores the new fields and answers as before.

Changes

ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex a15f4f6..0a854ac 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1449,14 +1449,20 @@ class ChatServer {
}
}
-- /** Send a command to the streamer's hardware client (Live). Silent when the streamer is unknown. */
-- _hardwareCommand(ws, stream, command, client) {
-- ctx.effects.hardwareCommand(stream.user_id, command, client.user?.display_name || `anon${client.anonId || ''}`).then((r) => {
-- if (r && r.ok) return;
-- if (r && r.reason === 'no_hardware') this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });
-- }).catch((err) => {
-- this.sendTo(ws, { type: 'system', message: err.message || 'Media command failed.' });
-- });
++ /**
++ * A robot command for the channel's OpenVibe.Bot robot, through Live (it forwards to Bot as this viewer, plan T15).
++ * The viewer is named by Network subject (none when not signed in: Bot only takes commands from people). Live's
++ * refusal comes back as a sentence for this viewer alone; a quiet refusal (a cooldown) says nothing.
++ */
++ async _hardwareCommand(ws, stream, command, client) {
++ try {
++ const subject = client.user ? await this._subjectOfUser(client.user) : null;
++ const r = await ctx.effects.hardwareCommand(stream.user_id, command, client.user?.display_name || `anon${client.anonId || ''}`,
++ { subject, userId: client.user?.id || null });
++ if (r && !r.ok && r.message) this.sendTo(ws, { type: 'system', message: r.message });
++ } catch (err) {
++ this.sendTo(ws, { type: 'system', message: err.message || 'The robot command failed.' });
++ }
}
/**
ddiff --git a/server/live-context.js b/server/live-context.js
iindex cee4369..27e8269 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -919,7 +919,9 @@ const effects = {
// bridge path the body carries none and Live's sendToConn answers the connection itself.
arenaCommand: (client, cmd, parts) => effect('arena-command', { client: clientHandle(client), cmd, parts }),
mediaQueue: async (op, args) => await effect('media-queue', { op, ...args }),
-- hardwareCommand: async (streamerUserId, command, fromUser) => await effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),
++ hardwareCommand: async (streamerUserId, command, fromUser, { subject = null, userId = null } = {}) => await effect('hardware', {
++ streamer_user_id: streamerUserId, command, from_user: fromUser, ...(subject ? { from_subject: subject } : {}), ...(userId ? { from_user_id: userId } : {}),
++ }),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
translate: (text, channelUserId) => effect('translate', { text, channel_user_id: channelUserId }).then((r) => r.translation || null),
notifyDm: (body) => fire('notify/dm', body),
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 2c00848..d2b7ac6 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -260,7 +260,8 @@ async function boot({ env = {} } = {}) {
if (body.op === 'state') return send(200, { state: live.mediaState });
if (body.op === 'skip') return send(200, { ended: live.mediaState.now_playing, next: live.mediaState.queue[0] || null });
return send(400, { error: 'unknown op' });
-- case 'hardware': return send(200, { ok: false, reason: 'no_hardware' });
++ // Live forwards a robot command to the channel's OpenVibe.Bot robot as the viewer: the stub says who it was asked as.
++ case 'hardware': return send(200, { ok: false, reason: 'no_robot', message: `No robot here (asked as ${body.from_subject || 'nobody'}, user ${body.from_user_id || '-'}).` });
case 'ensure-channel': {
let ch = [...live.channels.values()].find((c) => c.user_id === body.user_id);
if (!ch) { ch = { id: 100 + live.channels.size, user_id: body.user_id, title: 'New Channel' }; live.channels.set(ch.id, ch); }
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex d9926bd..2493ba9 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -329,7 +329,9 @@ t('media commands go to Live’s queue with the same replies', async () => {
await aliceWs.next((x) => x.type === 'system' && x.message === 'Only the streamer or a moderator can skip media.');
await h.sleep(1100);
aliceWs.sendJson({ type: 'chat', message: '!forward' });
-- await aliceWs.next((x) => x.type === 'system' && x.message === 'No hardware client connected.');
++ // The robot command goes to Live with the viewer's Network subject (Live forwards it to OpenVibe.Bot as them),
++ // and Live's refusal is shown to the viewer as it said it.
++ await aliceWs.next((x) => x.type === 'system' && x.message === `No robot here (asked as usr_01J9AAAAAAAAAAAAAAAAAAAAAA, user ${alice.id}).`);
});
t('upgrade guards: foreign origin refused, banned address refused (admins exempt)', async () => {