openvibers/openvibe.chat Commits

Use dedicated command registration grant in Chat (#525)

OP OpenVibers committed 2 days ago · f3a40a2996 · 7 files, +325 −25

* Use dedicated command registration grant in Chat

* Align Chat command grant with Contracts v0.406.0

* Keep Chat CI audio tests off failing Ubuntu package mirror

* Match slash registry tests to scoped moderation rights

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

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 43043ae..eaf8097 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -10,8 +10,8 @@ jobs:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
-- apt-packages: ffmpeg # sound uploads are probed and converted
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).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@15b927b7d9fe35fe64ee4b7f3b4792b46ebdbddb
ddiff --git a/package-lock.json b/package-lock.json
iindex 7cf1f2a..6248556 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex ea04951..e94f9f9 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/chat/room-slash.js b/server/chat/room-slash.js
iindex 112ddfb..184561d 100644
---- a/server/chat/room-slash.js
++++ b/server/chat/room-slash.js
@@@ -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>');
ddiff --git a/server/chat/slash-commands.js b/server/chat/slash-commands.js
iindex 997304b..9f07e98 100644
---- a/server/chat/slash-commands.js
++++ b/server/chat/slash-commands.js
@@@ -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);
ddiff --git a/test/room-slash.test.js b/test/room-slash.test.js
iindex 08f8c0a..ffb495d 100644
---- a/test/room-slash.test.js
++++ b/test/room-slash.test.js
@@@ -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' });
ddiff --git a/test/slash-commands.test.js b/test/slash-commands.test.js
iindex cf04f03..6e53f8e 100644
---- a/test/slash-commands.test.js
++++ b/test/slash-commands.test.js
@@@ -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 na
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 });
-- 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');
});