openvibers/openvibe.chat Commits

Add gated slash command registry API (#453)

OP OpenVibers committed 4 days ago · 4badcbe82b · 5 files, +167 −0

* Add gated slash command registry API

* Merge remote-tracking branch 'origin/main' into chat-v3-commands

* Test the slash command registry and gate the added commands

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 7b72948..becca6f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 23a4ed5..448def7 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.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;
ddiff --git a/server/chat/slash-commands.js b/server/chat/slash-commands.js
nnew file mode 100644
iindex 0000000..26195fd
---- /dev/null
++++ b/server/chat/slash-commands.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex f6b6191..817a4d1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/test/slash-commands.test.js b/test/slash-commands.test.js
nnew file mode 100644
iindex 0000000..cf04f03
---- /dev/null
++++ b/test/slash-commands.test.js
@@@ -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(); });