openvibers/openvibe.chat Commits

Send arena-command replies returned by the effect to the sender

OP OpenVibers committed 4 Oct 2026 · b990738311 · 5 files, +33 −4

Asked: Follow-up of https://github.com/OpenVibers/OpenVibe.Live/pull/21 (Deliver chat only through Chat's ingress and drop the bridge outbox): its review asked for a change in OpenVibe.Chat that the PR cannot make from OpenVibe.Live. The review said: VERDICT: FIX
Harness job chat-follow-up-of-https-github-com-openv-205948.

Changes

ddiff --git a/docs/chat-ingress.md b/docs/chat-ingress.md
iindex 8189b7b..a6dc591 100644
---- a/docs/chat-ingress.md
++++ b/docs/chat-ingress.md
@@@ -49,6 +49,8 @@ and vibe-coding producers to `events`, the call invite/response `sendDm` calls t
`user` target, moderation writes and disconnects to
`moderation`, cache hints to `invalidate`, and presence reads to `/internal/chat/presence`.
Await the real message id; use one stable key per operation. Drain Live's bridge outbox before
--removing its sender. Live J4b moves arena-command replies into the Chat-effects response.
++removing its sender. Live J4b moves arena-command replies into the Chat-effects response; Chat sends
++each returned reply to the socket that ran the command (on the bridge path the response carries none
++and Live answers the connection itself).
For deploy notices, Live publishes `live.release.deployed` through Events. If Events outbox
initialization fails, Live must leave `deploy_last_announced` unchanged so the next boot retries.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex d6146e1..21591e4 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -1221,8 +1221,15 @@ class ChatServer {
const cmd = parts[0].toLowerCase();
// Arena: !hype / !beef / !arena (Live server/arena/arena-chat.js) — chat can only hype; the Arena
-- // is pure mic. Live runs the command and answers this socket through the bridge (sendToConn).
-- if (ctx.ARENA_COMMANDS.includes(cmd)) { ctx.effects.arenaCommand(client, cmd, parts); return; }
++ // is pure mic. Live runs the command. When Live delivers through Chat's ingress (LIVE_CHAT_INGRESS)
++ // the sender-only replies come back in the effect response and this socket sends them; on the old
++ // bridge path the body carries none and Live's sendToConn answers the connection itself.
++ if (ctx.ARENA_COMMANDS.includes(cmd)) {
++ ctx.effects.arenaCommand(client, cmd, parts).then((r) => {
++ for (const reply of (r && Array.isArray(r.replies) ? r.replies : [])) this.sendTo(ws, reply);
++ }).catch((err) => console.warn('[Chat] arena-command:', err.message));
++ return;
++ }
if (cmd === '!gotti') {
const username = client.user ? client.user.display_name : client.anonId;
ddiff --git a/server/live-context.js b/server/live-context.js
iindex 26a4caf..9cc0229 100644
---- a/server/live-context.js
++++ b/server/live-context.js
@@@ -915,7 +915,10 @@ const effects = {
// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
chatMessage: async (body) => await effect('chat-message', body),
aiModCommand: (channelUserId, streamId, args, opts) => effect('ai/mod-command', { channel_user_id: channelUserId, stream_id: streamId, args, by: opts && opts.by }).then((r) => r.reply),
-- arenaCommand: (client, cmd, parts) => fire('arena-command', { client: clientHandle(client), cmd, parts }),
++ // Live runs the command (arena-chat.js). When it delivers through Chat's ingress the sender-only
++ // replies come back in this response; the caller sends them to that sender's socket. On the old
++ // 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 }),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 5686992..262ae29 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -134,6 +134,7 @@ async function boot({ env = {} } = {}) {
hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 1,
mediaState: { queue: [], now_playing: null },
++ arenaReplies: null, // what an ingress-delivered arena-command returns as sender-only replies
seenTokens: [],
requests: [], // every path Chat called, in order
};
@@@ -254,6 +255,10 @@ async function boot({ env = {} } = {}) {
case 'paste': return send(200, { paste: { slug: 'abc123' } });
case 'translate': return send(200, { translation: null });
case 'ai/mod-command': return send(200, { reply: `AI viewers: ${(body.args || []).join(' ')} ok` });
++ case 'arena-command': {
++ const replies = Array.isArray(live.arenaReplies) ? live.arenaReplies : [];
++ return send(200, replies.length ? { handled: true, replies } : { handled: true });
++ }
case 'media-queue':
if (body.op === 'add') {
if (!body.input) return send(400, { error: 'Give me a link to queue.' });
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
iindex 2de7c4d..fa0a94b 100644
---- a/test/ws-protocol.test.js
++++ b/test/ws-protocol.test.js
@@@ -304,6 +304,18 @@ t('!hype goes to Live; Live answers this socket through the bridge (sendToConn)'
await aliceWs.next((m) => m.type === 'system' && m.message === 'Hyped!');
});
++t('arena replies returned by the effect reach the sender through Chat’s ingress', async () => {
++ await h.sleep(1100);
++ h.live.arenaReplies = [{ type: 'system', message: 'Ingress hyped!' }];
++ try {
++ aliceWs.sendJson({ type: 'chat', message: '!beef' });
++ const m = await aliceWs.next((x) => x.type === 'system' && x.message === 'Ingress hyped!');
++ assert.strictEqual(m.message, 'Ingress hyped!');
++ const fx = h.live.effects.filter((e) => e.name === 'arena-command').pop();
++ assert.strictEqual(fx.body.cmd, '!beef');
++ } finally { h.live.arenaReplies = null; }
++});
++
t('media commands go to Live’s queue with the same replies', async () => {
await h.sleep(1100);
aliceWs.sendJson({ type: 'chat', message: '!sr https://youtu.be/x' });