openvibers/openvibe.chat Commits

Complete server slash commands and app registration (#516)

OP OpenVibers committed 3 days ago · 9bd118e1e6 · 16 files, +369 −20

* Complete server slash commands and app registration

* Expose poll counts to channel members

* Cover command data in account erasure and schema checks

* Validate app commands against the contract

* Merge Chat main into V3 command branch

* Use released config grant for command registration

Changes

ddiff --git a/migrations/0075_server_commands.sql b/migrations/0075_server_commands.sql
nnew file mode 100644
iindex 0000000..77f7c08
---- /dev/null
++++ b/migrations/0075_server_commands.sql
@@@ -0,0 +1,45 @@
++-- phase: expand
++ALTER TABLE chat_server_members ADD COLUMN IF NOT EXISTS nickname text COLLATE "C";
++CREATE TABLE IF NOT EXISTS chat_server_timeouts (
++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ expires_at timestamptz NOT NULL,
++ actor_user_id bigint NOT NULL,
++ PRIMARY KEY (server_id, user_id)
++);
++CREATE TABLE IF NOT EXISTS chat_command_polls (
++ id bigserial PRIMARY KEY,
++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
++ author_user_id bigint NOT NULL,
++ question text COLLATE "C" NOT NULL,
++ options jsonb NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ closes_at timestamptz NOT NULL DEFAULT now() + interval '1 day'
++);
++CREATE TABLE IF NOT EXISTS chat_command_poll_votes (
++ poll_id bigint NOT NULL REFERENCES chat_command_polls(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ option_index integer NOT NULL,
++ PRIMARY KEY (poll_id, user_id)
++);
++CREATE TABLE IF NOT EXISTS chat_command_reminders (
++ id bigserial PRIMARY KEY,
++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ due_at timestamptz NOT NULL,
++ text text COLLATE "C" NOT NULL,
++ delivered_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS idx_chat_command_reminders_due ON chat_command_reminders(due_at) WHERE delivered_at IS NULL;
++CREATE TABLE IF NOT EXISTS chat_registered_commands (
++ id bigserial PRIMARY KEY,
++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
++ application_id text COLLATE "C" NOT NULL,
++ source text COLLATE "C" NOT NULL CHECK (source IN ('app', 'actor')),
++ name text COLLATE "C" NOT NULL,
++ description text COLLATE "C" NOT NULL,
++ options jsonb NOT NULL DEFAULT '[]'::jsonb,
++ response_template text COLLATE "C" NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ UNIQUE (server_id, name)
++);
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex fc2c58c..45d4d70 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -12,6 +12,31 @@
var feed = document.getElementById('oc-feed');
var form = document.getElementById('oc-compose');
var input = document.getElementById('oc-input');
++ if (cfg.commandServer && input && form) {
++ var commandMenu = el('div', 'oc-command-menu'); commandMenu.setAttribute('role', 'listbox'); commandMenu.hidden = true;
++ form.insertBefore(commandMenu, input);
++ var commandTimer = 0;
++ input.addEventListener('input', function () {
++ window.clearTimeout(commandTimer);
++ var found = /^\/([a-z0-9_-]*)$/i.exec(input.value);
++ if (!found) { commandMenu.hidden = true; return; }
++ commandTimer = window.setTimeout(function () {
++ fetch('/api/chat/commands?server=' + encodeURIComponent(cfg.commandServer) + '&q=' + encodeURIComponent(found[1]), { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : { commands: [] }; }).then(function (body) {
++ if (input.value !== '/' + found[1]) return;
++ commandMenu.replaceChildren();
++ (body.commands || []).forEach(function (command) {
++ var button = el('button', 'ovk-btn ovk-btn--quiet', '/' + command.name + ' · ' + command.description);
++ button.type = 'button'; button.setAttribute('role', 'option');
++ button.addEventListener('click', function () { input.value = '/' + command.name + ' '; commandMenu.hidden = true; input.focus(); });
++ commandMenu.appendChild(button);
++ });
++ commandMenu.hidden = !commandMenu.children.length;
++ }).catch(function () { commandMenu.hidden = true; });
++ }, 120);
++ });
++ input.addEventListener('keydown', function (event) { if (event.key === 'Escape') commandMenu.hidden = true; });
++ }
var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));
function textWithRoomEmoji(parent, text) {
if (!cfg.serverEmojiSlug) { textWithLinks(parent, text); return; }
ddiff --git a/server/app.js b/server/app.js
iindex dd4cab2..bc04a45 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -145,6 +145,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-tickets'));
// Vibe answers a person through another service (Bot's Vibe by voice; capability chat.vibe.ask).
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-vibe'));
++ app.use('/api/chat/commands/registered', express.json({ limit: '16kb' }), require('./chat/slash-commands').internalRouter);
// OpenVibe.Events deliveries (live.release.deployed, network.module.updated): signature v2 over
// the raw body, so this router reads the body itself (server/events/consumer.js).
if (events) app.use('/internal/events', events.router);
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 894a65f..5606fdd 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -54,7 +54,11 @@ const EXPORTS = [
['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
-- ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.roles, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
++ ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.roles, m.nickname, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
++ ['command_polls.json', `SELECT id, room_id, question, options, created_at, closes_at FROM chat_command_polls WHERE author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['command_poll_votes.json', `SELECT poll_id, option_index FROM chat_command_poll_votes WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
++ ['command_reminders.json', `SELECT room_id, due_at, text, delivered_at FROM chat_command_reminders WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
++ ['server_timeouts.json', `SELECT server_id, expires_at FROM chat_server_timeouts WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['stage_speaker_actions.json', `SELECT room_id, user_id, hand_raised, invited_by, updated_at FROM chat_stage_speakers WHERE user_id = ANY(@ids) OR invited_by = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],
['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@@ -131,6 +135,10 @@ const ERASE_FIRST = [
['conversations', 'DELETE FROM dm_conversations WHERE id NOT IN (SELECT conversation_id FROM dm_participants)'],
['room_memberships', 'DELETE FROM room_members WHERE user_id = ANY(@ids)'],
['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],
++ ['command_poll_votes', 'DELETE FROM chat_command_poll_votes WHERE user_id = ANY(@ids)'],
++ ['command_reminders', 'DELETE FROM chat_command_reminders WHERE user_id = ANY(@ids)'],
++ ['server_timeouts', 'DELETE FROM chat_server_timeouts WHERE user_id = ANY(@ids) OR actor_user_id = ANY(@ids)'],
++ [null, 'UPDATE chat_command_polls SET author_user_id = 0 WHERE author_user_id = ANY(@ids)'],
];
const ERASE_AFTER_ROOMS = [
['stage_speaker_roles', 'DELETE FROM chat_stage_speakers WHERE user_id = ANY(@ids)'],
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 30f9efc..98fc895 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -759,7 +759,9 @@ class ChatServer {
const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;
if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }
try {
-- const message = await rooms.post(room, client.user, msg.message);
++ const command = await require('./room-slash').parse(room, client.user, msg.message);
++ if (command.handled && !command.text) { this.sendTo(ws, { type: 'system', message: command.reply }); break; }
++ const message = await rooms.post(room, client.user, command.handled ? command.text : msg.message);
this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
} catch (err) {
this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
ddiff --git a/server/chat/room-slash.js b/server/chat/room-slash.js
nnew file mode 100644
iindex 0000000..112ddfb
---- /dev/null
++++ b/server/chat/room-slash.js
@@@ -0,0 +1,111 @@
++'use strict';
++const db = require('../db/database');
++const rooms = require('../rooms/rooms');
++const servers = require('../rooms/servers');
++const ctx = require('../live-context');
++const config = require('../config');
++
++const error = (status, code, message) => { throw new rooms.RoomError(status, code, message); };
++const RANK = servers.RANK;
++
++async function parse(room, user, raw) {
++ if (!config.slashCommands.enabled || !/^\/[a-z][a-z0-9_-]*(?:\s|$)/i.test(String(raw || ''))) return { handled: false };
++ const match = /^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i.exec(String(raw).trim());
++ if (!match) return { handled: false };
++ const name = match[1].toLowerCase(), args = (match[2] || '').trim();
++ const a = await rooms.access(room, user);
++ if (!a.post) error(a.read ? 403 : 404, 'commands.forbidden', 'You cannot write here');
++ if (name === 'me') {
++ if (!args) error(422, 'commands.options', 'Use /me <action>');
++ return { handled: true, text: `* ${user.display_name || user.username} ${args}` };
++ }
++ if (name === 'shrug') return { handled: true, text: `${args ? `${args} ` : ''}¯\\_(ツ)_/¯` };
++ 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>');
++ await db.run('UPDATE chat_server_members SET nickname = ? WHERE server_id = ? AND user_id = ?', [args, room.server_id, user.id]);
++ return { handled: true, reply: `Your server nickname is ${args}.` };
++ }
++ if (name === 'mute' || name === 'timeout') {
++ if (!room.server_id) error(422, 'commands.server_only', `/${name} works in a server channel`);
++ const server = await servers.byId(room.server_id);
++ const access = await servers.access(server, user);
++ if (!access.moderate) error(403, 'commands.forbidden', 'You do not have permission');
++ const bits = args.split(/\s+/), target = await ctx.getUserByUsername(bits[0].replace(/^@/, ''));
++ if (!target || !(await servers.roleIn(server.id, target.id))) error(404, 'commands.no_user', 'No such member');
++ const role = await servers.roleIn(server.id, target.id);
++ if (role === 'owner' || (!access.staff && RANK[access.role] >= RANK[role])) error(403, 'commands.rank', 'You cannot time out this member');
++ const seconds = name === 'mute' ? 300 : Number(bits[1] || 300);
++ if (!Number.isInteger(seconds) || seconds < 60 || seconds > 86400) error(422, 'commands.options', 'Timeout must be 60–86400 seconds');
++ await db.run(`INSERT INTO chat_server_timeouts (server_id, user_id, expires_at, actor_user_id) VALUES (?, ?, now() + (? * interval '1 second'), ?)
++ ON CONFLICT (server_id, user_id) DO UPDATE SET expires_at = excluded.expires_at, actor_user_id = excluded.actor_user_id`, [server.id, target.id, seconds, user.id]);
++ await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: user.id, target_user_id: target.id, action_type: 'server_timeout', details: { seconds } });
++ return { handled: true, reply: `${target.username} timed out for ${seconds}s.` };
++ }
++ if (name === 'poll') {
++ const parts = args.split('|').map((s) => s.trim());
++ if (parts.length < 3 || parts.length > 11 || !parts[0] || parts.some((s) => !s || s.length > 200)) error(422, 'commands.options', 'Use /poll Question | Option 1 | Option 2');
++ const question = parts.shift();
++ const r = await db.run('INSERT INTO chat_command_polls (room_id, author_user_id, question, options) VALUES (?, ?, ?, ?::jsonb)', [room.id, user.id, question, JSON.stringify(parts)]);
++ return { handled: true, text: `Poll #${r.lastInsertRowid}: ${question}\n${parts.map((s, i) => `${i + 1}. ${s}`).join('\n')}\nVote with /vote ${r.lastInsertRowid} <option number>.`, poll_id: Number(r.lastInsertRowid) };
++ }
++ if (name === 'vote') {
++ const bits = args.split(/\s+/);
++ if (bits.length !== 2) error(422, 'commands.options', 'Use /vote <poll number> <option number>');
++ const cast = await vote(room, user, bits[0], bits[1]);
++ return { handled: true, reply: `Vote ${cast.option} recorded for poll #${cast.poll_id}.` };
++ }
++ if (name === 'remind') {
++ const found = /^(\d+)([mhd])\s+([\s\S]+)$/.exec(args);
++ if (!found || found[3].length > 500) error(422, 'commands.options', 'Use /remind 10m <message> (m, h or d; up to 30 days)');
++ const scale = { m: 60, h: 3600, d: 86400 }[found[2]], seconds = Number(found[1]) * scale;
++ if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > 30 * 86400) error(422, 'commands.options', 'Reminder must be 1 minute to 30 days away');
++ await db.run(`INSERT INTO chat_command_reminders (room_id, user_id, due_at, text) VALUES (?, ?, now() + (? * interval '1 second'), ?)`, [room.id, user.id, seconds, found[3]]);
++ return { handled: true, reply: `I will remind you in ${found[1]}${found[2]}.` };
++ }
++ const app = room.server_id && await db.get('SELECT response_template FROM chat_registered_commands WHERE server_id = ? AND name = ?', [room.server_id, name]);
++ if (app) return { handled: true, text: app.response_template.replace(/\{args\}/g, args).slice(0, 2000) };
++ error(422, 'commands.unknown', `Unknown command: /${name}`);
++}
++
++async function vote(room, user, pollId, option) {
++ const a = await rooms.access(room, user);
++ if (!a.post) error(a.read ? 403 : 404, 'commands.forbidden', 'You cannot vote here');
++ const poll = await db.get('SELECT * FROM chat_command_polls WHERE id = ? AND room_id = ? AND closes_at > now()', [pollId, room.id]);
++ if (!poll) error(404, 'commands.poll_not_found', 'Poll is closed or not found');
++ const options = Array.isArray(poll.options) ? poll.options : JSON.parse(poll.options);
++ const n = Number(option);
++ if (!Number.isInteger(n) || n < 1 || n > options.length) error(422, 'commands.options', 'No such option');
++ await db.run('INSERT INTO chat_command_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?) ON CONFLICT (poll_id, user_id) DO UPDATE SET option_index = excluded.option_index', [poll.id, user.id, n]);
++ return { poll_id: Number(poll.id), option: n };
++}
++
++async function pollView(room, pollId) {
++ const poll = await db.get('SELECT * FROM chat_command_polls WHERE id = ? AND room_id = ?', [pollId, room.id]);
++ if (!poll) error(404, 'commands.poll_not_found', 'No such poll');
++ const options = Array.isArray(poll.options) ? poll.options : JSON.parse(poll.options);
++ const votes = await db.all('SELECT option_index, COUNT(*) AS count FROM chat_command_poll_votes WHERE poll_id = ? GROUP BY option_index', [poll.id]);
++ const count = new Map(votes.map((v) => [Number(v.option_index), Number(v.count)]));
++ return { id: Number(poll.id), question: poll.question, closes_at: poll.closes_at, options: options.map((label, i) => ({ index: i + 1, label, votes: count.get(i + 1) || 0 })) };
++}
++
++let timer = null;
++async function deliverReminders() {
++ if (!config.slashCommands.enabled) return;
++ const due = await db.all('SELECT * FROM chat_command_reminders WHERE due_at <= now() AND delivered_at IS NULL ORDER BY due_at LIMIT 50');
++ for (const reminder of due) {
++ // Claim once before sending, including on multi-instance Chat. A failed post is still completed;
++ // a person who lost access must never gain it through a reminder.
++ const claimed = await db.run('UPDATE chat_command_reminders SET delivered_at = now() WHERE id = ? AND delivered_at IS NULL', [reminder.id]);
++ if (!claimed.changes) continue;
++ const room = await db.get('SELECT * FROM rooms WHERE id = ? AND archived_at IS NULL', [reminder.room_id]), user = await ctx.getUserById(reminder.user_id);
++ if (!room || !user) continue;
++ try {
++ const message = await rooms.post(room, user, `Reminder: ${reminder.text}`, { ai: false });
++ require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
++ } catch (err) { console.warn('[Commands] reminder skipped:', err.code || err.message); }
++ }
++}
++function start() { if (!timer) { timer = setInterval(() => { deliverReminders().catch((err) => console.warn('[Commands] reminder:', err.message)); }, 15000); timer.unref(); } }
++function stop() { if (timer) clearInterval(timer); timer = null; }
++module.exports = { parse, vote, pollView, deliverReminders, start, stop };
ddiff --git a/server/chat/slash-commands.js b/server/chat/slash-commands.js
iindex 26195fd..997304b 100644
---- a/server/chat/slash-commands.js
++++ b/server/chat/slash-commands.js
@@@ -2,9 +2,15 @@
const express = require('express');
const config = require('../config');
const { requireAuth } = require('../auth/auth');
++const serviceAuth = require('../net/service-auth');
++const ctx = require('../live-context');
++const { validate } = require('openvibe-contracts');
++const db = require('../db/database');
++const servers = require('../rooms/servers');
const { limits } = require('../net/actor-limits');
const router = express.Router();
const readLimit = limits('chat.slash.commands.read', { minute: 60, hour: 600 });
++const writeLimit = limits('chat.slash.commands.register', { minute: 10, hour: 100 });
// Metadata consumed by the composer. Executions continue through Chat's authenticated command
// handler, so moderation commands run with the socket owner's existing rights.
@@@ -14,20 +20,67 @@ const BUILT_INS = [
{ 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' },
++ { name:'nick', description:'Set your server nickname', options:[{name:'name',description:'New nickname',type:'string',required:true}] },
++ { name:'poll', description:'Ask a poll in this channel', options:[{name:'question',description:'Question and options separated by |',type:'string',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}] },
];
--router.get('/', requireAuth, readLimit, (req,res) => {
++async function serverFor(req, res) {
++ if (!req.query.server) return null;
++ const server = await servers.bySlug(req.query.server);
++ if (!server || !(await servers.access(server, req.user)).read) { res.status(404).json({ error: 'No such server', code: 'servers.not_found' }); return false; }
++ return server;
++}
++router.get('/', requireAuth, readLimit, async (req,res) => {
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
++ const server = await serverFor(req, res); if (server === false) return;
const query = String(req.query.q || '').replace(/^\//,'').toLowerCase();
-- const commands = BUILT_INS.filter(c => !query || c.name.startsWith(query)).map(({permission,...c}) => c);
++ 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' }));
++ 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 });
++ }
res.json({ commands });
});
--router.get('/autocomplete', requireAuth, readLimit, (req,res) => {
++router.get('/autocomplete', requireAuth, readLimit, async (req,res) => {
if (!config.slashCommands.enabled) return res.status(404).json({ error:'Not found', code:'commands.disabled' });
++ 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);
++ 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 (!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) });
++ 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)}%`]);
++ return res.json({ choices: rows.map((r) => r.username) });
++ }
++ res.json({ choices: (option?.choices || []).filter(c => c.toLowerCase().includes(query)).slice(0,25) });
});
--module.exports = { router, BUILT_INS };
++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) => {
++ 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' });
++ const server = await servers.bySlug(req.body?.server);
++ if (!server || !(await servers.access(server, user)).manage) return res.status(403).json({ error:'Only server managers register commands', code:'commands.forbidden' });
++ const command = req.body?.command || {}, name = String(command.name || '').toLowerCase(), options = command.options;
++ const template = String(req.body?.response_template || '');
++ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name) || !validate('chat.command@1', command).valid || BUILT_INS.some((c) => c.name === name) || typeof command.description !== 'string' || command.description.length > 100 || !Array.isArray(options) || options.length > 25 ||
++ options.some((o) => !/^[a-z][a-z0-9_-]{0,31}$/.test(o.name) || !['string','integer','boolean','user','channel'].includes(o.type) || typeof o.description !== 'string' || o.description.length > 100 || typeof o.required !== 'boolean') ||
++ !template || template.length > 2000) return res.status(422).json({ error:'Invalid command', code:'commands.invalid' });
++ try {
++ const source = principal.sub.startsWith('agent:') ? 'actor' : 'app';
++ await db.run('INSERT INTO chat_registered_commands (server_id, application_id, source, name, description, options, response_template) VALUES (?, ?, ?, ?, ?, ?::jsonb, ?)', [server.id, principal.sub, source, name, command.description, JSON.stringify(options), template]);
++ 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) => {
++ 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);
++ if (!user || user.is_banned || !server || !(await servers.access(server, user)).manage) return res.status(403).json({ error:'Not allowed', code:'commands.forbidden' });
++ await db.run('DELETE FROM chat_registered_commands WHERE server_id = ? AND application_id = ? AND name = ?', [server.id, principal.sub, req.params.name]);
++ res.json({ ok: true });
++});
++module.exports = { router, internalRouter, BUILT_INS };
ddiff --git a/server/index.js b/server/index.js
iindex 82649a1..4bac9cf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -91,6 +91,7 @@ async function start() {
require('./ai/person-memory').start();
// Workspace rooms' queues (server/rooms/workspace.js): the OpenVibe.Actor tasks they started, followed every 10 s.
require('./rooms/workspace').start();
++ require('./chat/room-slash').start();
// OpenVibe's own computers for parties (server/rooms/party-pool.js): release holds that went idle and seat the
// line once a minute. The owner switch is read at each placement, so this is safe while it is off.
require('./rooms/party-pool').start({ intervalMs: 60_000 });
ddiff --git a/server/net/service-auth.js b/server/net/service-auth.js
iindex b7930bb..bb07cd2 100644
---- a/server/net/service-auth.js
++++ b/server/net/service-auth.js
@@@ -72,14 +72,14 @@ function viaProxy(req) {
}
/** Express guard for one capability on an /internal route. */
--function guard(capability) {
++function guard(capability, { allowPublic = false } = {}) {
const registered = !!capabilities.get(capability);
return function serviceGuard(req, res, next) {
check(req, res, next).catch(next);
};
async function check(req, res, next) {
const ctx = http.requestContext(req.headers);
-- if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
++ if (!allowPublic && viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
const auth = String(req.headers.authorization || '');
if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
const r = await verifyServiceToken(auth.slice(7).trim(), { ...keys().verifyOptions, issuer: config.networkUrl, audience: AUDIENCE, contracts });
@@@ -89,7 +89,7 @@ function guard(capability) {
? capabilities.check(r.claims, capability)
: (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
-- req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti, person: r.claims.on_behalf_of || null };
next();
}
}
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 668477e..e7395c1 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -410,7 +410,9 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
-- CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE u.display_name END AS display_name,
++ CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE COALESCE((
++ SELECT sm.nickname FROM chat_server_members sm JOIN rooms sr ON sr.server_id = sm.server_id
++ WHERE sr.id = m.room_id AND sm.user_id = m.user_id), u.display_name) END AS display_name,
m.agent_stream, u.profile_color, u.role AS user_role
FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id AND m.ai_member_id IS NULL
LEFT JOIN room_ai_members a ON a.id = m.ai_member_id`;
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 31125e8..48da329 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -215,16 +215,29 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
-- const message = await rooms.post(room, req.user, req.body && req.body.message, {
++ const command = await require('../chat/room-slash').parse(room, req.user, req.body && req.body.message);
++ if (command.handled && !command.text) return res.json({ reply: command.reply });
++ const message = await rooms.post(room, req.user, command.handled ? command.text : req.body && req.body.message, {
timezone: req.body && req.body.timezone,
files: require('./files').attachmentsFromBody(req.body),
reply_to_id: req.body && req.body.reply_to_id,
});
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
-- res.status(201).json({ message });
++ res.status(201).json({ message, ...(command.poll_id ? { poll_id: command.poll_id } : {}) });
} catch (err) { send(res, err); }
});
++router.post('/:slug/polls/:id/vote', requireAuth, postLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try { res.json(await require('../chat/room-slash').vote(room, req.user, req.params.id, req.body && req.body.option)); }
++ catch (err) { send(res, err); }
++});
++router.get('/:slug/polls/:id', optionalAuth, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try { res.json({ poll: await require('../chat/room-slash').pollView(room, req.params.id) }); }
++ catch (err) { send(res, err); }
++});
++
// A reaction on a room message (room_message_reactions, migration 0038): the same one again takes it back. The
// author's activity gets one row per person and message (server/chat/activity.js reaction).
router.post('/:slug/messages/:id/react', requireAuth, reactLimit, async (req, res) => {
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex ab64606..1d469a7 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -114,7 +114,8 @@ async function channelAccess(room, user) {
if (room.stage_channel && user && a.member) stageSpeaker = !!await db.get('SELECT 1 FROM chat_stage_speakers WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? (room.stage_channel ? (stageSpeaker ? 'speaker' : 'participant') : rooms.joinRoleOf(room)) : 'member';
const banned = rooms.siteBanned(user);
-- const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);
++ const timedOut = !!user && !!await db.get('SELECT 1 FROM chat_server_timeouts WHERE server_id = ? AND user_id = ? AND expires_at > now()', [server.id, user.id]);
++ const post = !!user && a.read && !banned && !timedOut && a.member && rooms.POSTERS[kind].has(vrole);
const join = kind === 'call' && a.read;
const talk = join && !!user && !banned && ((a.member && rooms.TALKERS.has(vrole)) || a.staff);
return { role: vrole, kind, read: a.read, post, join, talk, moderate: a.moderate, manage: a.manage, server: server.slug, server_role: srole };
@@@ -354,7 +355,7 @@ async function setFeatured(server, actor, on) {
/** The server's people by rank (blocked last, only for moderators). A member's picked roles ride along. */
async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {
const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
-- const rows = await db.all(`SELECT m.user_id, m.role, m.roles, m.joined_at, u.username, u.display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members m
++ const rows = await db.all(`SELECT m.user_id, m.role, m.roles, m.joined_at, u.username, COALESCE(m.nickname, u.display_name) AS display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members m
LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
return rows.map((p) => ({ ...p, roles: parseList(p.roles).map(String).filter(Boolean) }));
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 72aab6a..0c3585a 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -302,6 +302,7 @@ ${sheet}`,
page: {
view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
++ commandServer: config.slashCommands.enabled ? server.slug : null,
serverEmojiSlug: config.emotes.serverEmojiEnabled ? server.slug : null,
serverEmojis: serverEmojis.map((e) => ({ name: e.code, url: e.media_url })),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
@@@ -328,7 +329,7 @@ ${sheet}`,
${invitePanel}
<span class="oc-live" id="oc-live" hidden>● live</span>
${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
--${aiHere}${errorOf(req)}
++${aiHere}${errorOf(req)}${req.query.saved ? notice('ok', String(req.query.saved).slice(0, 200)) : ''}
${isVoice ? callPanel(room, rcan, me) : ''}
<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me, emojiMap)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
${composer}`,
@@@ -706,7 +707,9 @@ ${errorOf(req)}${action}</section>`,
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
if (room.forum_space) return res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
req.back = `/s/${server.slug}/${room.channel_slug}`;
-- const message = await rooms.post(room, actor.user, req.body && req.body.message);
++ const command = await require('../chat/room-slash').parse(room, actor.user, req.body && req.body.message);
++ if (command.handled && !command.text) return res.redirect(303, `/s/${server.slug}/${room.channel_slug}?saved=${encodeURIComponent(command.reply)}#oc-compose`);
++ const message = await rooms.post(room, actor.user, command.handled ? command.text : req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.redirect(303, `/s/${server.slug}/${room.channel_slug}#oc-compose`);
}));
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex efab102..f9fc70c 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -34,7 +34,7 @@ t('export, then erase and confirm', async () => {
{
const missing = await uncovered(d, {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
-- 'chat_stage_speakers',
++ 'chat_stage_speakers', 'chat_command_poll_votes', 'chat_command_reminders', 'chat_server_timeouts',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'dm_link_preview_settings', 'emotes', 'inbox_actions',
'person_chat_memory', 'person_chat_memory_settings',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions', 'chat_activity',
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 1fade4f..e122a5f 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -40,6 +40,7 @@ const TABLES = [
'party_pool_settings', 'party_pool_machines', 'party_pool_holds', // 0064: OpenVibe's own computers for parties
'link_previews', 'link_preview_cache', // 0065: address previews beside the message, and the address cache
'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
++ 'chat_server_timeouts', 'chat_command_polls', 'chat_command_poll_votes', 'chat_command_reminders', 'chat_registered_commands',
'room_memory', // 0071: what a room keeps between summaries
];
const INDEXES = [
ddiff --git a/test/room-slash.test.js b/test/room-slash.test.js
nnew file mode 100644
iindex 0000000..08f8c0a
---- /dev/null
++++ b/test/room-slash.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++const assert = require('assert');
++const { ids, serviceAuth } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++const t = suite('room-slash');
++let h, owner, member, other, slug, room;
++const api = (method, path, user, body) => h.http(method, path, { token: user && user.token, body });
++
++t('boot a server channel', async () => {
++ h = await boot({ env: { CHAT_SLASH_COMMANDS_ENABLED: 'true' } });
++ owner = h.addUser('commandowner', { subject: ids.newId('user') });
++ member = h.addUser('commandmember', { subject: ids.newId('user') });
++ other = h.addUser('commandother', { subject: ids.newId('user') });
++ for (const user of [owner, member, other]) await h.ctx.upsertUser(h.live.users.get(user.id));
++ const created = await api('POST', '/api/chat/servers', owner, { name: 'Command Tests' });
++ assert.strictEqual(created.status, 201, created.text);
++ slug = created.body.server.slug;
++ room = (await api('GET', `/api/chat/servers/${slug}`, owner)).body.channels[0].room;
++ await api('POST', `/api/chat/servers/${slug}/join`, member);
++});
++
++t('registry and autocomplete use the current server', async () => {
++ const listed = await api('GET', `/api/chat/commands?server=${slug}`, member);
++ 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');
++ 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',
++ 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}!' };
++ 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, 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'));
++ const sent = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/hello world' });
++ assert.strictEqual(sent.body.message.message, 'Hello, world!');
++});
++
++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: '/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' });
++ assert.strictEqual(r.body.message.display_name, 'Moon');
++ assert.match(r.body.message.message, /¯\\_\(ツ\)_\/¯/);
++ r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/poll Lunch? | Soup | Salad' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.ok(r.body.poll_id > 0);
++ const voted = await api('POST', `/api/chat/rooms/${room}/polls/${r.body.poll_id}/vote`, owner, { option: 2 });
++ assert.deepStrictEqual([voted.status, voted.body.option], [200, 2]);
++ const result = await api('GET', `/api/chat/rooms/${room}/polls/${r.body.poll_id}`, member);
++ assert.deepStrictEqual(result.body.poll.options.map((o) => o.votes), [0, 1]);
++ r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/remind 1h bring soup' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual((await h.db.all('SELECT text FROM chat_command_reminders WHERE room_id = ?', [(await h.db.get('SELECT id FROM rooms WHERE slug = ?', [room])).id])).length, 1);
++});
++
++t('timeouts require rank and block posts, with an audit row', async () => {
++ let r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: '/timeout commandowner 300' });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'commands.forbidden']);
++ r = await api('POST', `/api/chat/rooms/${room}/messages`, owner, { message: '/timeout commandmember 300' });
++ assert.deepStrictEqual([r.status, r.body.reply], [200, 'commandmember timed out for 300s.']);
++ r = await api('POST', `/api/chat/rooms/${room}/messages`, member, { message: 'hello' });
++ assert.strictEqual(r.status, 403);
++ assert.ok((await h.db.all("SELECT id FROM moderation_actions WHERE action_type = 'server_timeout'")).length);
++ assert.strictEqual((await api('POST', `/api/chat/rooms/${room}/messages`, other, { message: '/nick Nope' })).status, 403);
++});
++
++t.run(async () => { if (h) await h.close(); });