openvibers/openvibe.chat Commits
Use dedicated command registration grant in Chat (#525)
Changes
@@@ -10,8 +10,8 @@ jobs:test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:-- apt-packages: ffmpeg # sound uploads are probed and convertedcontracts-service: chat++ test: 'PATH="node_modules/@ffmpeg-installer/linux-x64:node_modules/@ffprobe-installer/linux-x64:$PATH" npm test'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@15b927b7d9fe35fe64ee4b7f3b4792b46ebdbddb@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","pg": "^8.23.0",@@@ -25,7 +25,9 @@},"devDependencies": {"@electric-sql/pglite": "^0.5.8",-- "@electric-sql/pglite-socket": "0.2.11"++ "@electric-sql/pglite-socket": "0.2.11",++ "@ffmpeg-installer/ffmpeg": "^1.1.0",++ "@ffprobe-installer/ffprobe": "^2.1.2"},"engines": {"node": ">=22.0.0"@@@ -290,6 +292,279 @@"@electric-sql/pglite-pgvector": "0.0.9"}},++ "node_modules/@ffmpeg-installer/darwin-arm64": {++ "version": "4.1.5",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",++ "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",++ "cpu": [++ "arm64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",++ "optional": true,++ "os": [++ "darwin"++ ]++ },++ "node_modules/@ffmpeg-installer/darwin-x64": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",++ "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "LGPL-2.1",++ "optional": true,++ "os": [++ "darwin"++ ]++ },++ "node_modules/@ffmpeg-installer/ffmpeg": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",++ "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",++ "dev": true,++ "license": "LGPL-2.1",++ "optionalDependencies": {++ "@ffmpeg-installer/darwin-arm64": "4.1.5",++ "@ffmpeg-installer/darwin-x64": "4.1.0",++ "@ffmpeg-installer/linux-arm": "4.1.3",++ "@ffmpeg-installer/linux-arm64": "4.1.4",++ "@ffmpeg-installer/linux-ia32": "4.1.0",++ "@ffmpeg-installer/linux-x64": "4.1.0",++ "@ffmpeg-installer/win32-ia32": "4.1.0",++ "@ffmpeg-installer/win32-x64": "4.1.0"++ }++ },++ "node_modules/@ffmpeg-installer/linux-arm": {++ "version": "4.1.3",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",++ "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",++ "cpu": [++ "arm"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffmpeg-installer/linux-arm64": {++ "version": "4.1.4",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",++ "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",++ "cpu": [++ "arm64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffmpeg-installer/linux-ia32": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",++ "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",++ "cpu": [++ "ia32"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffmpeg-installer/linux-x64": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",++ "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffmpeg-installer/win32-ia32": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",++ "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",++ "cpu": [++ "ia32"++ ],++ "dev": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "win32"++ ]++ },++ "node_modules/@ffmpeg-installer/win32-x64": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",++ "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "license": "GPLv3",++ "optional": true,++ "os": [++ "win32"++ ]++ },++ "node_modules/@ffprobe-installer/darwin-arm64": {++ "version": "5.0.1",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz",++ "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==",++ "cpu": [++ "arm64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "LGPL-2.1",++ "optional": true,++ "os": [++ "darwin"++ ]++ },++ "node_modules/@ffprobe-installer/darwin-x64": {++ "version": "5.1.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz",++ "integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "darwin"++ ]++ },++ "node_modules/@ffprobe-installer/ffprobe": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz",++ "integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==",++ "dev": true,++ "license": "LGPL-2.1",++ "engines": {++ "node": ">=14.21.2"++ },++ "optionalDependencies": {++ "@ffprobe-installer/darwin-arm64": "5.0.1",++ "@ffprobe-installer/darwin-x64": "5.1.0",++ "@ffprobe-installer/linux-arm": "5.2.0",++ "@ffprobe-installer/linux-arm64": "5.2.0",++ "@ffprobe-installer/linux-ia32": "5.2.0",++ "@ffprobe-installer/linux-x64": "5.2.0",++ "@ffprobe-installer/win32-ia32": "5.1.0",++ "@ffprobe-installer/win32-x64": "5.1.0"++ }++ },++ "node_modules/@ffprobe-installer/linux-arm": {++ "version": "5.2.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz",++ "integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==",++ "cpu": [++ "arm"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffprobe-installer/linux-arm64": {++ "version": "5.2.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz",++ "integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==",++ "cpu": [++ "arm64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffprobe-installer/linux-ia32": {++ "version": "5.2.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz",++ "integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==",++ "cpu": [++ "ia32"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffprobe-installer/linux-x64": {++ "version": "5.2.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz",++ "integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "hasInstallScript": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "linux"++ ]++ },++ "node_modules/@ffprobe-installer/win32-ia32": {++ "version": "5.1.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz",++ "integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==",++ "cpu": [++ "ia32"++ ],++ "dev": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "win32"++ ]++ },++ "node_modules/@ffprobe-installer/win32-x64": {++ "version": "5.1.0",++ "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz",++ "integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==",++ "cpu": [++ "x64"++ ],++ "dev": true,++ "license": "GPL-3.0",++ "optional": true,++ "os": [++ "win32"++ ]++ },"node_modules/@iovalkey/commands": {"version": "0.1.0","resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",@@@ -1638,9 +1913,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.399.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",++ "version": "0.406.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",++ "integrity": "sha512-V/Tjss2UWvzI551bw3ACb/RqWg0lChxGITxfCVZ8qou2Docg0pBMvIjkeQoib56yq3r6UZGB6DGl+pA+qfjOiA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","pg": "^8.23.0",@@@ -37,7 +37,9 @@},"devDependencies": {"@electric-sql/pglite": "^0.5.8",-- "@electric-sql/pglite-socket": "0.2.11"++ "@electric-sql/pglite-socket": "0.2.11",++ "@ffmpeg-installer/ffmpeg": "^1.1.0",++ "@ffprobe-installer/ffprobe": "^2.1.2"},"engines": {"node": ">=22.0.0"@@@ -20,6 +20,10 @@ async function parse(room, user, raw) {return { handled: true, text: `* ${user.display_name || user.username} ${args}` };}if (name === 'shrug') return { handled: true, text: `${args ? `${args} ` : ''}¯\\_(ツ)_/¯` };++ if (name === 'help') {++ const available = require('./slash-commands').BUILT_INS.filter((command) => !command.permission || a.moderate);++ return { handled: true, reply: `Commands: ${available.map((command) => `/${command.name}`).join(', ')}` };++ }if (name === 'nick') {if (!room.server_id) error(422, 'commands.server_only', '/nick works in a server channel');if (!/^[^\x00-\x1f\x7f]{2,32}$/.test(args)) error(422, 'commands.options', 'Use /nick <2–32 character name>');@@@ -25,6 +25,11 @@ const BUILT_INS = [{ name:'vote', description:'Vote in a poll', options:[{name:'poll',description:'Poll number',type:'integer',required:true},{name:'option',description:'Option number',type:'integer',required:true}] },{ name:'remind', description:'Remind yourself in this channel', options:[{name:'when',description:'10m, 2h or 1d and a message',type:'string',required:true}] },];++function optionsOf(command) {++ if (Array.isArray(command?.options)) return command.options;++ try { const options = JSON.parse(command?.options || '[]'); return Array.isArray(options) ? options : []; }++ catch { return []; }++}async function serverFor(req, res) {if (!req.query.server) return null;const server = await servers.bySlug(req.query.server);@@@ -36,9 +41,9 @@ router.get('/', requireAuth, readLimit, async (req,res) => {const server = await serverFor(req, res); if (server === false) return;const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();const access = server && await servers.access(server, req.user);-- const commands = BUILT_INS.filter(c => (!c.permission || (server ? access.moderate : true)) && (!query || c.name.startsWith(query))).map(({permission,...c}) => ({ ...c, source: 'builtin' }));++ const commands = BUILT_INS.filter(c => (!c.permission || access?.moderate) && (!query || c.name.startsWith(query))).map(({permission,...c}) => ({ ...c, source: 'builtin' }));if (server) for (const row of await db.all('SELECT name, description, options, source, application_id FROM chat_registered_commands WHERE server_id = ? ORDER BY name', [server.id])) {-- if (!query || row.name.startsWith(query)) commands.push({ name: row.name, description: row.description, options: row.options, source: row.source, application_id: row.application_id });++ if (!query || row.name.startsWith(query)) commands.push({ name: row.name, description: row.description, options: optionsOf(row), source: row.source, application_id: row.application_id });}res.json({ commands });});@@@ -47,7 +52,8 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {const server = await serverFor(req, res); if (server === false) return;const name = String(req.query.command || '').replace(/^\//,'').toLowerCase();const command = BUILT_INS.find(c => c.name === name) || (server && await db.get('SELECT options FROM chat_registered_commands WHERE server_id = ? AND name = ?', [server.id, name]));-- const option = command?.options.find(o => o.name === String(req.query.option || ''));++ if (command?.permission && !(server && (await servers.access(server, req.user)).moderate)) return res.status(403).json({ error: 'You do not have permission', code: 'commands.forbidden' });++ const option = optionsOf(command).find(o => o.name === String(req.query.option || ''));const query = String(req.query.q || '').toLowerCase();if (option?.type === 'user' && server) {const rows = await db.all(`SELECT u.username FROM chat_server_members m JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? AND m.role != 'blocked' AND lower(u.username) LIKE ? ORDER BY u.username LIMIT 25`, [server.id, `${query.slice(0, 30)}%`]);@@@ -56,9 +62,7 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {res.json({ choices: (option?.choices || []).filter(c => c.toLowerCase().includes(query)).slice(0,25) });});const internalRouter = express.Router();--// Command registration is server configuration. Use the released config grant until--// the dedicated command grant in the next Contracts release is available to Chat.--internalRouter.post('/', serviceAuth.guard('chat.config.manage', { allowPublic: true }), writeLimit, async (req, res) => {++internalRouter.post('/', serviceAuth.guard('chat.commands.register', { allowPublic: true }), writeLimit, async (req, res) => {if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);if (!user || user.is_banned || !/^(app|agent):/.test(principal.sub)) return res.status(403).json({ error:'A person and an approved app or agent grant are required', code:'commands.grant_required' });@@@ -75,7 +79,7 @@ internalRouter.post('/', serviceAuth.guard('chat.config.manage', { allowPublic:res.status(201).json({ command: { name, description: command.description, options, source, application_id: principal.sub } });} catch (err) { res.status(409).json({ error:'Command name is already used', code:'commands.name_taken' }); }});--internalRouter.delete('/:name', serviceAuth.guard('chat.config.manage', { allowPublic: true }), writeLimit, async (req, res) => {++internalRouter.delete('/:name', serviceAuth.guard('chat.commands.register', { allowPublic: true }), writeLimit, async (req, res) => {if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);const server = await servers.bySlug(req.query.server);@@@ -24,34 +24,48 @@ t('registry and autocomplete use the current server', async () => {assert.strictEqual(listed.status, 200, listed.text);for (const name of ['me', 'shrug', 'nick', 'poll', 'remind']) assert.ok(listed.body.commands.some((c) => c.name === name), name);assert.ok(!listed.body.commands.some((c) => c.name === 'timeout'), 'member does not see moderator commands');++ assert.strictEqual((await api('GET', '/api/chat/commands', member)).body.commands.some((c) => c.name === 'timeout'), false);++ assert.strictEqual((await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=timeout&option=user`, member)).status, 403);const suggested = await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=timeout&option=user&q=commandm`, owner);assert.deepStrictEqual(suggested.body.choices, ['commandmember']);});t('an approved app registers a command only for a server it manages', async () => {const now = Math.floor(Date.now() / 1000);-- const token = (user, cap) => serviceAuth.signServiceToken({-- iss: h.ISS, sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', actor_type: 'app',++ const token = (user, cap, sub = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3') => serviceAuth.signServiceToken({++ iss: h.ISS, sub, actor_type: sub.startsWith('agent:') ? 'agent' : 'app',project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', env: 'production', aud: ['openvibe.chat'], cap,on_behalf_of: user.subject_id, iat: now, exp: now + 300, jti: `tok_${Math.random().toString(36).slice(2)}`,}, h.keys.privateKey);-- const body = { server: slug, command: { name: 'hello', description: 'Say hello', options: [] }, response_template: 'Hello, {args}!' };++ const body = { server: slug, command: { name: 'hello', description: 'Say hello', options: [{ name: 'style', description: 'Greeting style', type: 'string', required: false, choices: ['hi', 'hey'] }] }, response_template: 'Hello, {args}!' };let r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, []), body });assert.strictEqual(r.status, 403, r.text);-- r = await h.http('POST', '/api/chat/commands/registered', { token: token(member, ['chat.config.manage']), body });-- assert.strictEqual(r.status, 403, r.text);r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, ['chat.config.manage']), body });++ assert.strictEqual(r.status, 403, r.text);++ r = await h.http('POST', '/api/chat/commands/registered', { token: token(member, ['chat.commands.register']), body });++ assert.strictEqual(r.status, 403, r.text);++ r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, ['chat.commands.register']), body });assert.strictEqual(r.status, 201, r.text);const listed = await api('GET', `/api/chat/commands?server=${slug}`, member);assert.ok(listed.body.commands.some((c) => c.name === 'hello' && c.source === 'app'));++ assert.deepStrictEqual(listed.body.commands.find((c) => c.name === 'hello').options, body.command.options);++ assert.deepStrictEqual((await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=hello&option=style&q=he`, member)).body.choices, ['hey']);const sent = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/hello world' });assert.strictEqual(sent.body.message.message, 'Hello, world!');++ const agent = token(owner, ['chat.commands.register'], 'agent:agt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3');++ r = await h.http('POST', '/api/chat/commands/registered', { token: agent, body: { server: slug, command: { name: 'welcome', description: 'Welcome a member', options: [] }, response_template: 'Welcome, {args}!' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.command.source, 'actor');});t('messages, nicknames, polls, reminders and vote work as the sender', async () => {let r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/me waves' });assert.strictEqual(r.status, 201, r.text);assert.match(r.body.message.message, /waves$/);++ r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/help' });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.body.reply, /\/remind/);++ assert.doesNotMatch(r.body.reply, /\/timeout/);r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/nick Moon' });assert.deepStrictEqual([r.status, r.body.reply], [200, 'Your server nickname is Moon.']);r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/shrug okay' });@@@ -23,14 +23,15 @@ t('boot: a stream room, its moderator and two viewers', async () => {await h.ctx.sync();});--t('registry: signed-in only, built-ins without their permission, q filters by name', async () => {++t('registry: signed-in only, no unscoped moderator commands, 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);++ for (const n of ['me', 'shrug', 'help']) assert.ok(names.includes(n), n);++ for (const n of ['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]]);@@@ -39,11 +40,11 @@ t('registry: signed-in only, built-ins without their permission, q filters by naassert.deepStrictEqual(r.body.commands.map((c) => c.name), ['shrug']);});--t('autocomplete: only an option with choices answers, filtered and capped', async () => {++t('autocomplete: moderator choices require a server, other options answer', 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']);++ assert.strictEqual(r.status, 403);r = await h.http('GET', '/api/chat/commands/autocomplete?command=%2Ftimeout&option=seconds&q=3', { token: alice.token });-- assert.deepStrictEqual(r.body.choices, ['300']);++ assert.strictEqual(r.status, 403);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');});
@@ -10,8 +10,8 @@ jobs:
@@ -10,8 +10,8 @@ jobs:
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
- apt-packages: ffmpeg # sound uploads are probed and converted
contracts-service: chat
contracts-service: chat
+ test: 'PATH="node_modules/@ffmpeg-installer/linux-x64:node_modules/@ffprobe-installer/linux-x64:$PATH" npm test'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@15b927b7d9fe35fe64ee4b7f3b4792b46ebdbddb
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@15b927b7d9fe35fe64ee4b7f3b4792b46ebdbddb
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -25,7 +25,9 @@
@@ -25,7 +25,9 @@
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
- "@electric-sql/pglite-socket": "0.2.11"
+ "@electric-sql/pglite-socket": "0.2.11",
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
+ "@ffprobe-installer/ffprobe": "^2.1.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -290,6 +292,279 @@
@@ -290,6 +292,279 @@
"@electric-sql/pglite-pgvector": "0.0.9"
"@electric-sql/pglite-pgvector": "0.0.9"
}
}
},
},
+ "node_modules/@ffmpeg-installer/darwin-arm64": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
+ "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/darwin-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
+ "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "LGPL-2.1",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/ffmpeg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
+ "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
+ "dev": true,
+ "license": "LGPL-2.1",
+ "optionalDependencies": {
+ "@ffmpeg-installer/darwin-arm64": "4.1.5",
+ "@ffmpeg-installer/darwin-x64": "4.1.0",
+ "@ffmpeg-installer/linux-arm": "4.1.3",
+ "@ffmpeg-installer/linux-arm64": "4.1.4",
+ "@ffmpeg-installer/linux-ia32": "4.1.0",
+ "@ffmpeg-installer/linux-x64": "4.1.0",
+ "@ffmpeg-installer/win32-ia32": "4.1.0",
+ "@ffmpeg-installer/win32-x64": "4.1.0"
+ }
+ },
+ "node_modules/@ffmpeg-installer/linux-arm": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
+ "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-arm64": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
+ "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
+ "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
+ "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/win32-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
+ "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/win32-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
+ "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@ffprobe-installer/darwin-arm64": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz",
+ "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "LGPL-2.1",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffprobe-installer/darwin-x64": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz",
+ "integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffprobe-installer/ffprobe": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz",
+ "integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==",
+ "dev": true,
+ "license": "LGPL-2.1",
+ "engines": {
+ "node": ">=14.21.2"
+ },
+ "optionalDependencies": {
+ "@ffprobe-installer/darwin-arm64": "5.0.1",
+ "@ffprobe-installer/darwin-x64": "5.1.0",
+ "@ffprobe-installer/linux-arm": "5.2.0",
+ "@ffprobe-installer/linux-arm64": "5.2.0",
+ "@ffprobe-installer/linux-ia32": "5.2.0",
+ "@ffprobe-installer/linux-x64": "5.2.0",
+ "@ffprobe-installer/win32-ia32": "5.1.0",
+ "@ffprobe-installer/win32-x64": "5.1.0"
+ }
+ },
+ "node_modules/@ffprobe-installer/linux-arm": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz",
+ "integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffprobe-installer/linux-arm64": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz",
+ "integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffprobe-installer/linux-ia32": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz",
+ "integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffprobe-installer/linux-x64": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz",
+ "integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffprobe-installer/win32-ia32": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz",
+ "integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@ffprobe-installer/win32-x64": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz",
+ "integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "GPL-3.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@iovalkey/commands": {
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
@@ -1638,9 +1913,9 @@
@@ -1638,9 +1913,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.399.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
+ "version": "0.406.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
+ "integrity": "sha512-V/Tjss2UWvzI551bw3ACb/RqWg0lChxGITxfCVZ8qou2Docg0pBMvIjkeQoib56yq3r6UZGB6DGl+pA+qfjOiA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -37,7 +37,9 @@
@@ -37,7 +37,9 @@
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
- "@electric-sql/pglite-socket": "0.2.11"
+ "@electric-sql/pglite-socket": "0.2.11",
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
+ "@ffprobe-installer/ffprobe": "^2.1.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -20,6 +20,10 @@ async function parse(room, user, raw) {
@@ -20,6 +20,10 @@ async function parse(room, user, raw) {
return { handled: true, text: `* ${user.display_name || user.username} ${args}` };
return { handled: true, text: `* ${user.display_name || user.username} ${args}` };
}
}
if (name === 'shrug') return { handled: true, text: `${args ? `${args} ` : ''}¯\\_(ツ)_/¯` };
if (name === 'shrug') return { handled: true, text: `${args ? `${args} ` : ''}¯\\_(ツ)_/¯` };
+ if (name === 'help') {
+ const available = require('./slash-commands').BUILT_INS.filter((command) => !command.permission || a.moderate);
+ return { handled: true, reply: `Commands: ${available.map((command) => `/${command.name}`).join(', ')}` };
+ }
if (name === 'nick') {
if (name === 'nick') {
if (!room.server_id) error(422, 'commands.server_only', '/nick works in a server channel');
if (!room.server_id) error(422, 'commands.server_only', '/nick works in a server channel');
if (!/^[^\x00-\x1f\x7f]{2,32}$/.test(args)) error(422, 'commands.options', 'Use /nick <2–32 character name>');
if (!/^[^\x00-\x1f\x7f]{2,32}$/.test(args)) error(422, 'commands.options', 'Use /nick <2–32 character name>');
@@ -25,6 +25,11 @@ const BUILT_INS = [
@@ -25,6 +25,11 @@ const BUILT_INS = [
{ name:'vote', description:'Vote in a poll', options:[{name:'poll',description:'Poll number',type:'integer',required:true},{name:'option',description:'Option number',type:'integer',required:true}] },
{ name:'vote', description:'Vote in a poll', options:[{name:'poll',description:'Poll number',type:'integer',required:true},{name:'option',description:'Option number',type:'integer',required:true}] },
{ name:'remind', description:'Remind yourself in this channel', options:[{name:'when',description:'10m, 2h or 1d and a message',type:'string',required:true}] },
{ name:'remind', description:'Remind yourself in this channel', options:[{name:'when',description:'10m, 2h or 1d and a message',type:'string',required:true}] },
];
];
+function optionsOf(command) {
+ if (Array.isArray(command?.options)) return command.options;
+ try { const options = JSON.parse(command?.options || '[]'); return Array.isArray(options) ? options : []; }
+ catch { return []; }
+}
async function serverFor(req, res) {
async function serverFor(req, res) {
if (!req.query.server) return null;
if (!req.query.server) return null;
const server = await servers.bySlug(req.query.server);
const server = await servers.bySlug(req.query.server);
@@ -36,9 +41,9 @@ router.get('/', requireAuth, readLimit, async (req,res) => {
@@ -36,9 +41,9 @@ router.get('/', requireAuth, readLimit, async (req,res) => {
const server = await serverFor(req, res); if (server === false) return;
const server = await serverFor(req, res); if (server === false) return;
const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();
const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();
const access = server && await servers.access(server, req.user);
const access = server && await servers.access(server, req.user);
- const commands = BUILT_INS.filter(c => (!c.permission || (server ? access.moderate : true)) && (!query || c.name.startsWith(query))).map(({permission,...c}) => ({ ...c, source: 'builtin' }));
+ const commands = BUILT_INS.filter(c => (!c.permission || access?.moderate) && (!query || c.name.startsWith(query))).map(({permission,...c}) => ({ ...c, source: 'builtin' }));
if (server) for (const row of await db.all('SELECT name, description, options, source, application_id FROM chat_registered_commands WHERE server_id = ? ORDER BY name', [server.id])) {
if (server) for (const row of await db.all('SELECT name, description, options, source, application_id FROM chat_registered_commands WHERE server_id = ? ORDER BY name', [server.id])) {
- if (!query || row.name.startsWith(query)) commands.push({ name: row.name, description: row.description, options: row.options, source: row.source, application_id: row.application_id });
+ if (!query || row.name.startsWith(query)) commands.push({ name: row.name, description: row.description, options: optionsOf(row), source: row.source, application_id: row.application_id });
}
}
res.json({ commands });
res.json({ commands });
});
});
@@ -47,7 +52,8 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {
@@ -47,7 +52,8 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {
const server = await serverFor(req, res); if (server === false) return;
const server = await serverFor(req, res); if (server === false) return;
const name = String(req.query.command || '').replace(/^\//,'').toLowerCase();
const name = String(req.query.command || '').replace(/^\//,'').toLowerCase();
const command = BUILT_INS.find(c => c.name === name) || (server && await db.get('SELECT options FROM chat_registered_commands WHERE server_id = ? AND name = ?', [server.id, name]));
const command = BUILT_INS.find(c => c.name === name) || (server && await db.get('SELECT options FROM chat_registered_commands WHERE server_id = ? AND name = ?', [server.id, name]));
- const option = command?.options.find(o => o.name === String(req.query.option || ''));
+ if (command?.permission && !(server && (await servers.access(server, req.user)).moderate)) return res.status(403).json({ error: 'You do not have permission', code: 'commands.forbidden' });
+ const option = optionsOf(command).find(o => o.name === String(req.query.option || ''));
const query = String(req.query.q || '').toLowerCase();
const query = String(req.query.q || '').toLowerCase();
if (option?.type === 'user' && server) {
if (option?.type === 'user' && server) {
const rows = await db.all(`SELECT u.username FROM chat_server_members m JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? AND m.role != 'blocked' AND lower(u.username) LIKE ? ORDER BY u.username LIMIT 25`, [server.id, `${query.slice(0, 30)}%`]);
const rows = await db.all(`SELECT u.username FROM chat_server_members m JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? AND m.role != 'blocked' AND lower(u.username) LIKE ? ORDER BY u.username LIMIT 25`, [server.id, `${query.slice(0, 30)}%`]);
@@ -56,9 +62,7 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {
@@ -56,9 +62,7 @@ router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {
res.json({ choices: (option?.choices || []).filter(c => c.toLowerCase().includes(query)).slice(0,25) });
res.json({ choices: (option?.choices || []).filter(c => c.toLowerCase().includes(query)).slice(0,25) });
});
});
const internalRouter = express.Router();
const internalRouter = express.Router();
-// Command registration is server configuration. Use the released config grant until
-// the dedicated command grant in the next Contracts release is available to Chat.
-internalRouter.post('/', serviceAuth.guard('chat.config.manage', { allowPublic: true }), writeLimit, async (req, res) => {
+internalRouter.post('/', serviceAuth.guard('chat.commands.register', { allowPublic: true }), writeLimit, async (req, res) => {
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);
const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);
if (!user || user.is_banned || !/^(app|agent):/.test(principal.sub)) return res.status(403).json({ error:'A person and an approved app or agent grant are required', code:'commands.grant_required' });
if (!user || user.is_banned || !/^(app|agent):/.test(principal.sub)) return res.status(403).json({ error:'A person and an approved app or agent grant are required', code:'commands.grant_required' });
@@ -75,7 +79,7 @@ internalRouter.post('/', serviceAuth.guard('chat.config.manage', { allowPublic:
@@ -75,7 +79,7 @@ internalRouter.post('/', serviceAuth.guard('chat.config.manage', { allowPublic:
res.status(201).json({ command: { name, description: command.description, options, source, application_id: principal.sub } });
res.status(201).json({ command: { name, description: command.description, options, source, application_id: principal.sub } });
} catch (err) { res.status(409).json({ error:'Command name is already used', code:'commands.name_taken' }); }
} catch (err) { res.status(409).json({ error:'Command name is already used', code:'commands.name_taken' }); }
});
});
-internalRouter.delete('/:name', serviceAuth.guard('chat.config.manage', { allowPublic: true }), writeLimit, async (req, res) => {
+internalRouter.delete('/:name', serviceAuth.guard('chat.commands.register', { allowPublic: true }), writeLimit, async (req, res) => {
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);
const principal = req.principal, user = principal.person && await ctx.getUserBySubject(principal.person);
const server = await servers.bySlug(req.query.server);
const server = await servers.bySlug(req.query.server);
@@ -24,34 +24,48 @@ t('registry and autocomplete use the current server', async () => {
@@ -24,34 +24,48 @@ t('registry and autocomplete use the current server', async () => {
assert.strictEqual(listed.status, 200, listed.text);
assert.strictEqual(listed.status, 200, listed.text);
for (const name of ['me', 'shrug', 'nick', 'poll', 'remind']) assert.ok(listed.body.commands.some((c) => c.name === name), name);
for (const name of ['me', 'shrug', 'nick', 'poll', 'remind']) assert.ok(listed.body.commands.some((c) => c.name === name), name);
assert.ok(!listed.body.commands.some((c) => c.name === 'timeout'), 'member does not see moderator commands');
assert.ok(!listed.body.commands.some((c) => c.name === 'timeout'), 'member does not see moderator commands');
+ assert.strictEqual((await api('GET', '/api/chat/commands', member)).body.commands.some((c) => c.name === 'timeout'), false);
+ assert.strictEqual((await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=timeout&option=user`, member)).status, 403);
const suggested = await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=timeout&option=user&q=commandm`, owner);
const suggested = await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=timeout&option=user&q=commandm`, owner);
assert.deepStrictEqual(suggested.body.choices, ['commandmember']);
assert.deepStrictEqual(suggested.body.choices, ['commandmember']);
});
});
t('an approved app registers a command only for a server it manages', async () => {
t('an approved app registers a command only for a server it manages', async () => {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
- const token = (user, cap) => serviceAuth.signServiceToken({
- iss: h.ISS, sub: 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', actor_type: 'app',
+ const token = (user, cap, sub = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3') => serviceAuth.signServiceToken({
+ iss: h.ISS, sub, actor_type: sub.startsWith('agent:') ? 'agent' : 'app',
project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', env: 'production', aud: ['openvibe.chat'], cap,
project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', env: 'production', aud: ['openvibe.chat'], cap,
on_behalf_of: user.subject_id, iat: now, exp: now + 300, jti: `tok_${Math.random().toString(36).slice(2)}`,
on_behalf_of: user.subject_id, iat: now, exp: now + 300, jti: `tok_${Math.random().toString(36).slice(2)}`,
}, h.keys.privateKey);
}, h.keys.privateKey);
- const body = { server: slug, command: { name: 'hello', description: 'Say hello', options: [] }, response_template: 'Hello, {args}!' };
+ const body = { server: slug, command: { name: 'hello', description: 'Say hello', options: [{ name: 'style', description: 'Greeting style', type: 'string', required: false, choices: ['hi', 'hey'] }] }, response_template: 'Hello, {args}!' };
let r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, []), body });
let r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, []), body });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
- r = await h.http('POST', '/api/chat/commands/registered', { token: token(member, ['chat.config.manage']), body });
- assert.strictEqual(r.status, 403, r.text);
r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, ['chat.config.manage']), body });
r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, ['chat.config.manage']), body });
+ assert.strictEqual(r.status, 403, r.text);
+ r = await h.http('POST', '/api/chat/commands/registered', { token: token(member, ['chat.commands.register']), body });
+ assert.strictEqual(r.status, 403, r.text);
+ r = await h.http('POST', '/api/chat/commands/registered', { token: token(owner, ['chat.commands.register']), body });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
const listed = await api('GET', `/api/chat/commands?server=${slug}`, member);
const listed = await api('GET', `/api/chat/commands?server=${slug}`, member);
assert.ok(listed.body.commands.some((c) => c.name === 'hello' && c.source === 'app'));
assert.ok(listed.body.commands.some((c) => c.name === 'hello' && c.source === 'app'));
+ assert.deepStrictEqual(listed.body.commands.find((c) => c.name === 'hello').options, body.command.options);
+ assert.deepStrictEqual((await api('GET', `/api/chat/commands/autocomplete?server=${slug}&command=hello&option=style&q=he`, member)).body.choices, ['hey']);
const sent = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/hello world' });
const sent = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/hello world' });
assert.strictEqual(sent.body.message.message, 'Hello, world!');
assert.strictEqual(sent.body.message.message, 'Hello, world!');
+ const agent = token(owner, ['chat.commands.register'], 'agent:agt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3');
+ r = await h.http('POST', '/api/chat/commands/registered', { token: agent, body: { server: slug, command: { name: 'welcome', description: 'Welcome a member', options: [] }, response_template: 'Welcome, {args}!' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.command.source, 'actor');
});
});
t('messages, nicknames, polls, reminders and vote work as the sender', async () => {
t('messages, nicknames, polls, reminders and vote work as the sender', async () => {
let r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/me waves' });
let r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/me waves' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.match(r.body.message.message, /waves$/);
assert.match(r.body.message.message, /waves$/);
+ r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/help' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.body.reply, /\/remind/);
+ assert.doesNotMatch(r.body.reply, /\/timeout/);
r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/nick Moon' });
r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/nick Moon' });
assert.deepStrictEqual([r.status, r.body.reply], [200, 'Your server nickname is Moon.']);
assert.deepStrictEqual([r.status, r.body.reply], [200, 'Your server nickname is Moon.']);
r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/shrug okay' });
r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/shrug okay' });
@@ -23,14 +23,15 @@ t('boot: a stream room, its moderator and two viewers', async () => {
@@ -23,14 +23,15 @@ t('boot: a stream room, its moderator and two viewers', async () => {
await h.ctx.sync();
await h.ctx.sync();
});
});
-t('registry: signed-in only, built-ins without their permission, q filters by name', async () => {
+t('registry: signed-in only, no unscoped moderator commands, q filters by name', async () => {
let r = await h.http('GET', '/api/chat/commands');
let r = await h.http('GET', '/api/chat/commands');
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
r = await h.http('GET', '/api/chat/commands', { token: alice.token });
r = await h.http('GET', '/api/chat/commands', { token: alice.token });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const names = r.body.commands.map((c) => c.name);
const names = r.body.commands.map((c) => c.name);
- for (const n of ['me', 'shrug', 'help', 'mute', 'timeout']) assert.ok(names.includes(n), n);
+ for (const n of ['me', 'shrug', 'help']) assert.ok(names.includes(n), n);
+ for (const n of ['mute', 'timeout']) assert.ok(!names.includes(n), n);
assert.ok(r.body.commands.every((c) => !('permission' in c)), 'rights are not advertised');
assert.ok(r.body.commands.every((c) => !('permission' in c)), 'rights are not advertised');
const shrug = r.body.commands.find((c) => c.name === 'shrug');
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]]);
assert.deepStrictEqual(shrug.options.map((o) => [o.name, o.type, o.required]), [['text', 'string', false]]);
@@ -39,11 +40,11 @@ t('registry: signed-in only, built-ins without their permission, q filters by na
@@ -39,11 +40,11 @@ t('registry: signed-in only, built-ins without their permission, q filters by na
assert.deepStrictEqual(r.body.commands.map((c) => c.name), ['shrug']);
assert.deepStrictEqual(r.body.commands.map((c) => c.name), ['shrug']);
});
});
-t('autocomplete: only an option with choices answers, filtered and capped', async () => {
+t('autocomplete: moderator choices require a server, other options answer', async () => {
let r = await h.http('GET', '/api/chat/commands/autocomplete?command=timeout&option=seconds', { token: alice.token });
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']);
+ assert.strictEqual(r.status, 403);
r = await h.http('GET', '/api/chat/commands/autocomplete?command=%2Ftimeout&option=seconds&q=3', { token: alice.token });
r = await h.http('GET', '/api/chat/commands/autocomplete?command=%2Ftimeout&option=seconds&q=3', { token: alice.token });
- assert.deepStrictEqual(r.body.choices, ['300']);
+ assert.strictEqual(r.status, 403);
r = await h.http('GET', '/api/chat/commands/autocomplete?command=shrug&option=text&q=hi', { token: alice.token });
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');
assert.deepStrictEqual(r.body.choices, [], 'an option without choices suggests nothing');
});
});