openvibers/openvibe.chat Commits
Add gated slash command registry API (#453)
Changes
@@@ -353,6 +353,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin// Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.app.use('/api/chat/feeds', require('./feeds/routes'));app.use('/api/chat/servers', require('./rooms/server-routes'));++ app.use('/api/chat/commands', require('./chat/slash-commands').router);// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.app.use('/api/chat/channels', require('./chat/channel-mod-routes'));// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.@@@ -1934,6 +1934,13 @@ class ChatServer {const args = parts.slice(1).join(' ');const argParts = parts.slice(1);++ // The composer's command set (registry, autocomplete and the commands it added) is a++ // default-off owner switch: with it off they are unknown like any other command.++ if (!config.slashCommands.enabled && (cmd === 'shrug' || cmd === 'mute')) {++ this.sendTo(ws, { type: 'system', message: `Unknown command: /${cmd}. Type /help for a list.` });++ return;++ }++switch (cmd) {case 'ai': {// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.@@@ -2069,6 +2076,20 @@ class ChatServer {break;}++ case 'shrug': {++ const content = `${args ? `${args} ` : ''}¯\\_(ツ)_/¯`;++ if (await this._commandTextBlocked(ws, client, content)) break;++ const username = client.user?.display_name || client.anonId;++ await this._broadcastToRoom(client, { type:'chat', username, core_username:client.user?.username || null,++ user_id:client.user?.id || null, role:client.user?.role || 'anon', message:content,++ timestamp:new Date().toISOString() });++ break;++ }++++ case 'mute':++ this.handleModAction(ws, client, 'timeout', `${args} 300`);++ break;++case 'ban':this.handleModAction(ws, client, 'ban', args);break;nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++const express = require('express');++const config = require('../config');++const { requireAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');++const router = express.Router();++const readLimit = limits('chat.slash.commands.read', { minute: 60, hour: 600 });++++// Metadata consumed by the composer. Executions continue through Chat's authenticated command++// handler, so moderation commands run with the socket owner's existing rights.++const BUILT_INS = [++ { name:'me', description:'Show an action', options:[{name:'text',description:'The action',type:'string',required:true}] },++ { name:'shrug', description:'Send a shrug', options:[{name:'text',description:'Optional message',type:'string',required:false}] },++ { name:'help', description:'Show available commands', options:[] },++ { name:'mute', description:'Mute a member for five minutes', options:[{name:'user',description:'Member',type:'user',required:true}], permission:'moderate' },++ { name:'timeout', description:'Temporarily mute a member', options:[{name:'user',description:'Member',type:'user',required:true},{name:'seconds',description:'Duration',type:'integer',required:false,choices:['60','300','900']}], permission:'moderate' },++];++router.get('/', requireAuth, readLimit, (req,res) => {++ if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });++ const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();++ const commands = BUILT_INS.filter(c => !query || c.name.startsWith(query)).map(({permission,...c}) => c);++ res.json({ commands });++});++router.get('/autocomplete', requireAuth, readLimit, (req,res) => {++ if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });++ const name = String(req.query.command || '').replace(/^\//,'').toLowerCase();++ const command = BUILT_INS.find(c => c.name === name);++ const option = command?.options.find(o => o.name === String(req.query.option || ''));++ if (!option?.choices) return res.json({ choices:[] });++ const query = String(req.query.q || '').toLowerCase();++ res.json({ choices:option.choices.filter(c => c.toLowerCase().includes(query)).slice(0,25) });++});++module.exports = { router, BUILT_INS };@@@ -106,6 +106,7 @@ module.exports = {maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),},cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },++ slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, false) },// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.live: {nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * The composer's slash commands: the registry and autocomplete Chat serves (GET /api/chat/commands,++ * behind the default-off CHAT_SLASH_COMMANDS_ENABLED switch), and the same commands over /ws/chat.++ * An execution keeps running through the socket's authenticated pipeline, so a moderating command++ * runs with the sender's own rights and nothing more; an unknown command is a plain sentence.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++++const t = suite('slash-commands');++let h, streamer, mod, alice, bob, channelId, streamId;++++t('boot: a stream room, its moderator and two viewers', async () => {++ h = await boot({ env: { CHAT_SLASH_COMMANDS_ENABLED: 'true' } });++ streamer = h.addUser('streamer', { role: 'streamer' });++ mod = h.addUser('moddy');++ alice = h.addUser('alice');++ bob = h.addUser('bob');++ channelId = h.addChannel(streamer.id, { moderators: [mod.id] });++ await h.db.addChannelModerator(channelId, mod.id, streamer.id);++ streamId = h.addStream(streamer.id, channelId);++ await h.ctx.sync();++});++++t('registry: signed-in only, built-ins without their permission, q filters by name', async () => {++ let r = await h.http('GET', '/api/chat/commands');++ assert.strictEqual(r.status, 401);++++ r = await h.http('GET', '/api/chat/commands', { token: alice.token });++ assert.strictEqual(r.status, 200, r.text);++ const names = r.body.commands.map((c) => c.name);++ for (const n of ['me', 'shrug', 'help', 'mute', 'timeout']) assert.ok(names.includes(n), n);++ assert.ok(r.body.commands.every((c) => !('permission' in c)), 'rights are not advertised');++ const shrug = r.body.commands.find((c) => c.name === 'shrug');++ assert.deepStrictEqual(shrug.options.map((o) => [o.name, o.type, o.required]), [['text', 'string', false]]);++++ r = await h.http('GET', '/api/chat/commands?q=%2Fsh', { token: alice.token });++ assert.deepStrictEqual(r.body.commands.map((c) => c.name), ['shrug']);++});++++t('autocomplete: only an option with choices answers, filtered and capped', async () => {++ let r = await h.http('GET', '/api/chat/commands/autocomplete?command=timeout&option=seconds', { token: alice.token });++ assert.deepStrictEqual(r.body.choices, ['60', '300', '900']);++ r = await h.http('GET', '/api/chat/commands/autocomplete?command=%2Ftimeout&option=seconds&q=3', { token: alice.token });++ assert.deepStrictEqual(r.body.choices, ['300']);++ r = await h.http('GET', '/api/chat/commands/autocomplete?command=shrug&option=text&q=hi', { token: alice.token });++ assert.deepStrictEqual(r.body.choices, [], 'an option without choices suggests nothing');++});++++t('the switch is off by default: the registry 404s and the added commands are unknown', async () => {++ const config = require('../server/config');++ config.slashCommands.enabled = false;++ try {++ const r = await h.http('GET', '/api/chat/commands', { token: alice.token });++ assert.deepStrictEqual([r.status, r.body.code], [404, 'commands.disabled']);++ const ws = await h.ws({ ip: '198.51.100.61', token: alice.token, stream: streamId });++ ws.sendJson({ type: 'join', streamId, token: alice.token });++ await ws.next((m) => m.type === 'auth');++ ws.sendJson({ type: 'chat', message: '/shrug off' });++ assert.strictEqual((await ws.next((m) => m.type === 'system')).message, 'Unknown command: /shrug. Type /help for a list.');++ ws.close();++ } finally { config.slashCommands.enabled = true; }++});++++t('an unknown command is refused with a plain sentence', async () => {++ const ws = await h.ws({ ip: '198.51.100.62', token: alice.token, stream: streamId });++ ws.sendJson({ type: 'join', streamId, token: alice.token });++ await ws.next((m) => m.type === 'auth');++ ws.sendJson({ type: 'chat', message: '/frobnicate loudly' });++ assert.strictEqual((await ws.next((m) => m.type === 'system')).message, 'Unknown command: /frobnicate. Type /help for a list.');++ ws.close();++});++++t('/shrug runs as the sender and reaches the room', async () => {++ const bobWs = await h.ws({ ip: '198.51.100.63', token: bob.token, stream: streamId });++ bobWs.sendJson({ type: 'join', streamId, token: bob.token });++ await bobWs.next((m) => m.type === 'auth');++ const aliceWs = await h.ws({ ip: '198.51.100.64', token: alice.token, stream: streamId });++ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });++ await aliceWs.next((m) => m.type === 'auth');++ aliceWs.sendJson({ type: 'chat', message: '/shrug hello' });++ const got = await bobWs.next((m) => m.type === 'chat');++ assert.strictEqual(got.message, 'hello ¯\\_(ツ)_/¯');++ assert.deepStrictEqual([got.username, got.core_username, got.user_id], ['Alice', 'alice', alice.id]);++ aliceWs.close(); bobWs.close();++});++++t('a moderating command runs with the sender\'s own rights and never more', async () => {++ const aliceWs = await h.ws({ ip: '198.51.100.65', token: alice.token, stream: streamId });++ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });++ await aliceWs.next((m) => m.type === 'auth');++ aliceWs.sendJson({ type: 'chat', message: '/mute bob' });++ assert.strictEqual((await aliceWs.next((m) => m.type === 'system')).message, 'You do not have permission.');++ assert.ok(!h.live.effects.some((e) => e.name === 'ban' && e.body.user_id === bob.id), 'a viewer changed nobody’s access');++ assert.ok(!(await h.db.all('SELECT id FROM moderation_actions')).length, 'and left no moderation row');++++ // The channel's moderator can, and the action is logged with the sender as its actor.++ const modWs = await h.ws({ ip: '198.51.100.66', token: mod.token, stream: streamId });++ modWs.sendJson({ type: 'join', streamId, token: mod.token });++ await modWs.next((m) => m.type === 'auth');++ modWs.sendJson({ type: 'chat', message: '/mute bob' });++ assert.strictEqual((await modWs.next((m) => m.type === 'system')).message, 'bob timed out for 300s.');++ const fx = h.live.effects.filter((e) => e.name === 'ban' && e.body.user_id === bob.id).pop();++ assert.deepStrictEqual([fx.body.reason, fx.body.actor_user_id], ['Timeout 300s', mod.id]);++ const rows = await h.db.all('SELECT action_type FROM moderation_actions');++ assert.deepStrictEqual(rows.map((r) => r.action_type), ['channel_timeout']);++ aliceWs.close(); modWs.close();++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -353,6 +353,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -353,6 +353,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.
// Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.
app.use('/api/chat/feeds', require('./feeds/routes'));
app.use('/api/chat/feeds', require('./feeds/routes'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
+ app.use('/api/chat/commands', require('./chat/slash-commands').router);
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
@@ -1934,6 +1934,13 @@ class ChatServer {
@@ -1934,6 +1934,13 @@ class ChatServer {
const args = parts.slice(1).join(' ');
const args = parts.slice(1).join(' ');
const argParts = parts.slice(1);
const argParts = parts.slice(1);
+ // The composer's command set (registry, autocomplete and the commands it added) is a
+ // default-off owner switch: with it off they are unknown like any other command.
+ if (!config.slashCommands.enabled && (cmd === 'shrug' || cmd === 'mute')) {
+ this.sendTo(ws, { type: 'system', message: `Unknown command: /${cmd}. Type /help for a list.` });
+ return;
+ }
+
switch (cmd) {
switch (cmd) {
case 'ai': {
case 'ai': {
// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
@@ -2069,6 +2076,20 @@ class ChatServer {
@@ -2069,6 +2076,20 @@ class ChatServer {
break;
break;
}
}
+ case 'shrug': {
+ const content = `${args ? `${args} ` : ''}¯\\_(ツ)_/¯`;
+ if (await this._commandTextBlocked(ws, client, content)) break;
+ const username = client.user?.display_name || client.anonId;
+ await this._broadcastToRoom(client, { type:'chat', username, core_username:client.user?.username || null,
+ user_id:client.user?.id || null, role:client.user?.role || 'anon', message:content,
+ timestamp:new Date().toISOString() });
+ break;
+ }
+
+ case 'mute':
+ this.handleModAction(ws, client, 'timeout', `${args} 300`);
+ break;
+
case 'ban':
case 'ban':
this.handleModAction(ws, client, 'ban', args);
this.handleModAction(ws, client, 'ban', args);
break;
break;
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+const express = require('express');
+const config = require('../config');
+const { requireAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
+const router = express.Router();
+const readLimit = limits('chat.slash.commands.read', { minute: 60, hour: 600 });
+
+// Metadata consumed by the composer. Executions continue through Chat's authenticated command
+// handler, so moderation commands run with the socket owner's existing rights.
+const BUILT_INS = [
+ { name:'me', description:'Show an action', options:[{name:'text',description:'The action',type:'string',required:true}] },
+ { name:'shrug', description:'Send a shrug', options:[{name:'text',description:'Optional message',type:'string',required:false}] },
+ { name:'help', description:'Show available commands', options:[] },
+ { name:'mute', description:'Mute a member for five minutes', options:[{name:'user',description:'Member',type:'user',required:true}], permission:'moderate' },
+ { name:'timeout', description:'Temporarily mute a member', options:[{name:'user',description:'Member',type:'user',required:true},{name:'seconds',description:'Duration',type:'integer',required:false,choices:['60','300','900']}], permission:'moderate' },
+];
+router.get('/', requireAuth, readLimit, (req,res) => {
+ if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
+ const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();
+ const commands = BUILT_INS.filter(c => !query || c.name.startsWith(query)).map(({permission,...c}) => c);
+ res.json({ commands });
+});
+router.get('/autocomplete', requireAuth, readLimit, (req,res) => {
+ if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
+ const name = String(req.query.command || '').replace(/^\//,'').toLowerCase();
+ const command = BUILT_INS.find(c => c.name === name);
+ const option = command?.options.find(o => o.name === String(req.query.option || ''));
+ if (!option?.choices) return res.json({ choices:[] });
+ const query = String(req.query.q || '').toLowerCase();
+ res.json({ choices:option.choices.filter(c => c.toLowerCase().includes(query)).slice(0,25) });
+});
+module.exports = { router, BUILT_INS };
@@ -106,6 +106,7 @@ module.exports = {
@@ -106,6 +106,7 @@ module.exports = {
maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
},
},
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
+ slashCommands: { enabled: bool(process.env.CHAT_SLASH_COMMANDS_ENABLED, false) },
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
live: {
live: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * The composer's slash commands: the registry and autocomplete Chat serves (GET /api/chat/commands,
+ * behind the default-off CHAT_SLASH_COMMANDS_ENABLED switch), and the same commands over /ws/chat.
+ * An execution keeps running through the socket's authenticated pipeline, so a moderating command
+ * runs with the sender's own rights and nothing more; an unknown command is a plain sentence.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+
+const t = suite('slash-commands');
+let h, streamer, mod, alice, bob, channelId, streamId;
+
+t('boot: a stream room, its moderator and two viewers', async () => {
+ h = await boot({ env: { CHAT_SLASH_COMMANDS_ENABLED: 'true' } });
+ streamer = h.addUser('streamer', { role: 'streamer' });
+ mod = h.addUser('moddy');
+ alice = h.addUser('alice');
+ bob = h.addUser('bob');
+ channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ streamId = h.addStream(streamer.id, channelId);
+ await h.ctx.sync();
+});
+
+t('registry: signed-in only, built-ins without their permission, q filters by name', async () => {
+ let r = await h.http('GET', '/api/chat/commands');
+ assert.strictEqual(r.status, 401);
+
+ r = await h.http('GET', '/api/chat/commands', { token: alice.token });
+ assert.strictEqual(r.status, 200, r.text);
+ const names = r.body.commands.map((c) => c.name);
+ for (const n of ['me', 'shrug', 'help', 'mute', 'timeout']) assert.ok(names.includes(n), n);
+ assert.ok(r.body.commands.every((c) => !('permission' in c)), 'rights are not advertised');
+ const shrug = r.body.commands.find((c) => c.name === 'shrug');
+ assert.deepStrictEqual(shrug.options.map((o) => [o.name, o.type, o.required]), [['text', 'string', false]]);
+
+ r = await h.http('GET', '/api/chat/commands?q=%2Fsh', { token: alice.token });
+ assert.deepStrictEqual(r.body.commands.map((c) => c.name), ['shrug']);
+});
+
+t('autocomplete: only an option with choices answers, filtered and capped', async () => {
+ let r = await h.http('GET', '/api/chat/commands/autocomplete?command=timeout&option=seconds', { token: alice.token });
+ assert.deepStrictEqual(r.body.choices, ['60', '300', '900']);
+ r = await h.http('GET', '/api/chat/commands/autocomplete?command=%2Ftimeout&option=seconds&q=3', { token: alice.token });
+ assert.deepStrictEqual(r.body.choices, ['300']);
+ r = await h.http('GET', '/api/chat/commands/autocomplete?command=shrug&option=text&q=hi', { token: alice.token });
+ assert.deepStrictEqual(r.body.choices, [], 'an option without choices suggests nothing');
+});
+
+t('the switch is off by default: the registry 404s and the added commands are unknown', async () => {
+ const config = require('../server/config');
+ config.slashCommands.enabled = false;
+ try {
+ const r = await h.http('GET', '/api/chat/commands', { token: alice.token });
+ assert.deepStrictEqual([r.status, r.body.code], [404, 'commands.disabled']);
+ const ws = await h.ws({ ip: '198.51.100.61', token: alice.token, stream: streamId });
+ ws.sendJson({ type: 'join', streamId, token: alice.token });
+ await ws.next((m) => m.type === 'auth');
+ ws.sendJson({ type: 'chat', message: '/shrug off' });
+ assert.strictEqual((await ws.next((m) => m.type === 'system')).message, 'Unknown command: /shrug. Type /help for a list.');
+ ws.close();
+ } finally { config.slashCommands.enabled = true; }
+});
+
+t('an unknown command is refused with a plain sentence', async () => {
+ const ws = await h.ws({ ip: '198.51.100.62', token: alice.token, stream: streamId });
+ ws.sendJson({ type: 'join', streamId, token: alice.token });
+ await ws.next((m) => m.type === 'auth');
+ ws.sendJson({ type: 'chat', message: '/frobnicate loudly' });
+ assert.strictEqual((await ws.next((m) => m.type === 'system')).message, 'Unknown command: /frobnicate. Type /help for a list.');
+ ws.close();
+});
+
+t('/shrug runs as the sender and reaches the room', async () => {
+ const bobWs = await h.ws({ ip: '198.51.100.63', token: bob.token, stream: streamId });
+ bobWs.sendJson({ type: 'join', streamId, token: bob.token });
+ await bobWs.next((m) => m.type === 'auth');
+ const aliceWs = await h.ws({ ip: '198.51.100.64', token: alice.token, stream: streamId });
+ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });
+ await aliceWs.next((m) => m.type === 'auth');
+ aliceWs.sendJson({ type: 'chat', message: '/shrug hello' });
+ const got = await bobWs.next((m) => m.type === 'chat');
+ assert.strictEqual(got.message, 'hello ¯\\_(ツ)_/¯');
+ assert.deepStrictEqual([got.username, got.core_username, got.user_id], ['Alice', 'alice', alice.id]);
+ aliceWs.close(); bobWs.close();
+});
+
+t('a moderating command runs with the sender\'s own rights and never more', async () => {
+ const aliceWs = await h.ws({ ip: '198.51.100.65', token: alice.token, stream: streamId });
+ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });
+ await aliceWs.next((m) => m.type === 'auth');
+ aliceWs.sendJson({ type: 'chat', message: '/mute bob' });
+ assert.strictEqual((await aliceWs.next((m) => m.type === 'system')).message, 'You do not have permission.');
+ assert.ok(!h.live.effects.some((e) => e.name === 'ban' && e.body.user_id === bob.id), 'a viewer changed nobody’s access');
+ assert.ok(!(await h.db.all('SELECT id FROM moderation_actions')).length, 'and left no moderation row');
+
+ // The channel's moderator can, and the action is logged with the sender as its actor.
+ const modWs = await h.ws({ ip: '198.51.100.66', token: mod.token, stream: streamId });
+ modWs.sendJson({ type: 'join', streamId, token: mod.token });
+ await modWs.next((m) => m.type === 'auth');
+ modWs.sendJson({ type: 'chat', message: '/mute bob' });
+ assert.strictEqual((await modWs.next((m) => m.type === 'system')).message, 'bob timed out for 300s.');
+ const fx = h.live.effects.filter((e) => e.name === 'ban' && e.body.user_id === bob.id).pop();
+ assert.deepStrictEqual([fx.body.reason, fx.body.actor_user_id], ['Timeout 300s', mod.id]);
+ const rows = await h.db.all('SELECT action_type FROM moderation_actions');
+ assert.deepStrictEqual(rows.map((r) => r.action_type), ['channel_timeout']);
+ aliceWs.close(); modWs.close();
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });