openvibers/openvibe.chat Commits
Send arena-command replies returned by the effect to the sender
Changes
@@@ -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 outboxinitialization fails, Live must leave `deploy_last_announced` unchanged so the next boot retries.@@@ -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;@@@ -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),@@@ -134,6 +134,7 @@ async function boot({ env = {} } = {}) {hang: false, // Live hangs: connections are accepted and never answerednextAnon: 1,mediaState: { queue: [], now_playing: null },++ arenaReplies: null, // what an ingress-delivered arena-command returns as sender-only repliesseenTokens: [],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.' });@@@ -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' });
@@ -49,6 +49,8 @@ and vibe-coding producers to `events`, the call invite/response `sendDm` calls t
@@ -49,6 +49,8 @@ and vibe-coding producers to `events`, the call invite/response `sendDm` calls t
`user` target, moderation writes and disconnects to
`user` target, moderation writes and disconnects to
`moderation`, cache hints to `invalidate`, and presence reads to `/internal/chat/presence`.
`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
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
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.
initialization fails, Live must leave `deploy_last_announced` unchanged so the next boot retries.
@@ -1221,8 +1221,15 @@ class ChatServer {
@@ -1221,8 +1221,15 @@ class ChatServer {
const cmd = parts[0].toLowerCase();
const cmd = parts[0].toLowerCase();
// Arena: !hype / !beef / !arena (Live server/arena/arena-chat.js) — chat can only hype; the Arena
// 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') {
if (cmd === '!gotti') {
const username = client.user ? client.user.display_name : client.anonId;
const username = client.user ? client.user.display_name : client.anonId;
@@ -915,7 +915,10 @@ const effects = {
@@ -915,7 +915,10 @@ const effects = {
// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
chatMessage: async (body) => await effect('chat-message', body),
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),
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 }),
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) => await effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
@@ -134,6 +134,7 @@ async function boot({ env = {} } = {}) {
@@ -134,6 +134,7 @@ async function boot({ env = {} } = {}) {
hang: false, // Live hangs: connections are accepted and never answered
hang: false, // Live hangs: connections are accepted and never answered
nextAnon: 1,
nextAnon: 1,
mediaState: { queue: [], now_playing: null },
mediaState: { queue: [], now_playing: null },
+ arenaReplies: null, // what an ingress-delivered arena-command returns as sender-only replies
seenTokens: [],
seenTokens: [],
requests: [], // every path Chat called, in order
requests: [], // every path Chat called, in order
};
};
@@ -254,6 +255,10 @@ async function boot({ env = {} } = {}) {
@@ -254,6 +255,10 @@ async function boot({ env = {} } = {}) {
case 'paste': return send(200, { paste: { slug: 'abc123' } });
case 'paste': return send(200, { paste: { slug: 'abc123' } });
case 'translate': return send(200, { translation: null });
case 'translate': return send(200, { translation: null });
case 'ai/mod-command': return send(200, { reply: `AI viewers: ${(body.args || []).join(' ')} ok` });
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':
case 'media-queue':
if (body.op === 'add') {
if (body.op === 'add') {
if (!body.input) return send(400, { error: 'Give me a link to queue.' });
if (!body.input) return send(400, { error: 'Give me a link to queue.' });
@@ -304,6 +304,18 @@ t('!hype goes to Live; Live answers this socket through the bridge (sendToConn)'
@@ -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!');
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 () => {
t('media commands go to Live’s queue with the same replies', async () => {
await h.sleep(1100);
await h.sleep(1100);
aliceWs.sendJson({ type: 'chat', message: '!sr https://youtu.be/x' });
aliceWs.sendJson({ type: 'chat', message: '!sr https://youtu.be/x' });