openvibers/openvibe.chat Commits
Workspace rooms take alerts: signed hooks (a Watch webhook action, an OpenVibe event, a line) become messages from Vibe and can queue a task for the hook's maker (#167)
Changes
@@@ -405,6 +405,14 @@ member on their first visit, and nobody else reads it, whatever role they once h- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socketsends `room_task`.++- **Alerts into the room** (`server/rooms/hooks.js`, migration 0029). The room's owner makes up to five hooks on its++ settings page: an address (`POST /api/chat/hooks/rh_…`) and a secret, shown once. A POST signed like an++ OpenVibe.Events delivery (X-OpenVibe-Signature-V2 over the raw body, ±5 minutes; an OpenVibe.Watch webhook action with++ the secret as a project `secret_ref` sends exactly that) becomes a message from the room's Vibe: a Watch firing++ ("🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)" and the watch's link), an OpenVibe event, or `{ "text" }`. A hook++ can also queue a task of a chosen kind for the person who made it, while they may still post there. Wrong or stale++ signature 401, unknown hook 404, unreadable body 422, past 60 an hour 429; a repeated X-OpenVibe-Delivery-Id changes++ nothing. Hooks are in their maker's export and go when the account does.- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot(`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Membersnnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- Incoming hooks for workspace rooms (server/rooms/hooks.js; owner 2026-10-25: the shared room "integrated with Watch,++-- Run and the rest"). A room's owner makes a hook: an address and a secret, shown once. A signed POST to it (signed like++-- an OpenVibe.Events delivery, as a Watch webhook action signs one) becomes a message from the room's Vibe, and, when++-- the hook says so, a task in the room's queue for the person who made the hook.++CREATE TABLE IF NOT EXISTS room_hooks (++ id text COLLATE "C" PRIMARY KEY, -- rh_<random>: the last part of the hook's address++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,++ label text NOT NULL, -- what the room sees before each message (<= 60)++ secret text NOT NULL, -- whsec_…: checks X-OpenVibe-Signature-V2 (shown once)++ queue_kind text COLLATE "C" -- NULL: a message only; else the kind of task each one queues++ CHECK (queue_kind IS NULL OR queue_kind IN ('answer', 'research', 'web', 'code', 'computer', 'other')),++ created_by bigint NOT NULL, -- Chat user id: queued tasks are theirs++ deliveries integer NOT NULL DEFAULT 0,++ last_at text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS room_hooks_room_idx ON room_hooks (room_id);++CREATE INDEX IF NOT EXISTS room_hooks_creator_idx ON room_hooks (created_by);@@@ -192,6 +192,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));++ // A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.++ app.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);app.use(express.json({ limit: '1mb' }));app.use(express.urlencoded({ extended: true, limit: '256kb' }));app.use(cookieParser());@@@ -61,6 +61,8 @@ const EXPORTS = [['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],++ // Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.++ ['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],@@@ -131,6 +133,8 @@ const ERASE_AFTER_ROOMS = [['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],++ // A hook queues work as the person who made it: it goes with them.++ ['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],// A workspace room's queue keeps the work the team relies on, without who asked or who took it.[null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],nnew file mode 100644@@@ -0,0 +1,152 @@++'use strict';++/**++ * Incoming hooks for workspace rooms (migrations/0029_room_hooks.sql): what other systems see lands in the room. A room's++ * owner makes a hook on the room's settings page; its address and secret are shown once.++ *++ * POST /api/chat/hooks/:id the raw body, signed like an OpenVibe.Events delivery (X-OpenVibe-Timestamp and++ * X-OpenVibe-Signature-V2 with the hook's secret, ±5 minutes: openvibe-sdk/events++ * signDeliveryHeaders makes them, and an OpenVibe.Watch webhook action sends them)++ *++ * The body, as JSON, is one of:++ * a Watch firing { type: 'watch.watch.triggered', watch: { name, url }, observation: { value, previous_value } }++ * an OpenVibe event { event: { type, data } } (data.text, .summary or .title is the line, if any)++ * a line { text } (or { message })++ * It becomes a message from the room's Vibe (its first AI member), "🔔 <label>: …". A hook with a queue kind also adds++ * a task of that kind to the room's queue for the person who made it (server/rooms/workspace.js), while they may still++ * post there: quick kinds run at once on their OpenVibe.Actor allowance.++ *++ * 202 when it is in the room. 401 for a missing, stale or wrong signature, 404 for no such hook (or a room that is no++ * longer a workspace), 422 for a body it cannot read, 429 past 60 deliveries an hour per hook. A repeat of the same++ * X-OpenVibe-Delivery-Id within the hour is answered 202 and changes nothing.++ */++const crypto = require('crypto');++const db = require('../db/database');++const rooms = require('./rooms');++++const MAX_PER_ROOM = 5;++const PER_HOUR = 60;++const LINE_MAX = 1500;++const ID_RE = /^rh_[A-Za-z0-9_-]{20,40}$/;++const KINDS = ['answer', 'research', 'web', 'code', 'computer', 'other'];++const recent = new Map(); // hook id → { since, n, ids: Set } for the rate limit and repeats, per process++++class HookError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }++const fail = (status, code, message) => { throw new HookError(status, code, message); };++const oneLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++const valueText = (v) => (v == null ? 'no value' : typeof v === 'string' ? v : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());++++const shape = (h) => ({ id: h.id, label: h.label, queue_kind: h.queue_kind || null, deliveries: Number(h.deliveries) || 0, last_at: h.last_at || null, created_at: h.created_at });++++async function mustManage(room, user) {++ const a = await rooms.access(room, user);++ if (!a.read) fail(404, 'rooms.not_found', 'No such room');++ if (!rooms.isWorkspace(room)) fail(409, 'hooks.not_workspace', 'Only workspace rooms take incoming hooks');++ if (!a.manage) fail(403, 'hooks.owner_only', 'Only the room\'s owner makes hooks');++ return a;++}++++async function list(room) {++ return (await db.all('SELECT * FROM room_hooks WHERE room_id = ? ORDER BY created_at, id', [room.id])).map(shape);++}++++/** A new hook. → { hook, secret }: the secret is never shown again. */++async function create(room, user, { label, queue_kind = null } = {}) {++ await mustManage(room, user);++ const name = oneLine(label, 60);++ if (name.length < 2) fail(422, 'hooks.label', 'Give the hook a short name, such as "Site errors"');++ const kind = KINDS.includes(queue_kind) ? queue_kind : null;++ const n = await db.get('SELECT COUNT(*) AS n FROM room_hooks WHERE room_id = ?', [room.id]);++ if (Number(n && n.n) >= MAX_PER_ROOM) fail(429, 'hooks.too_many', `At most ${MAX_PER_ROOM} hooks a room`);++ const id = `rh_${crypto.randomBytes(18).toString('base64url')}`;++ const secret = `whsec_${crypto.randomBytes(24).toString('base64url')}`;++ const row = await db.get('INSERT INTO room_hooks (id, room_id, label, secret, queue_kind, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *', [id, room.id, name, secret, kind, user.id]);++ return { hook: shape(row), secret };++}++++async function remove(room, user, id) {++ await mustManage(room, user);++ await db.run('DELETE FROM room_hooks WHERE room_id = ? AND id = ?', [room.id, String(id || '')]);++}++++/** The room's line for a body, or null when the body is none of the forms above. */++function lineOf(body) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) return null;++ if (body.type === 'watch.watch.triggered' && body.watch && typeof body.watch === 'object') {++ const o = body.observation || {};++ const was = o.previous_value === undefined || o.previous_value === null ? '' : ` (was ${oneLine(valueText(o.previous_value), 200)})`;++ const url = /^https:\/\//.test(String(body.watch.url || '')) ? `\n${oneLine(body.watch.url, 500)}` : '';++ return { title: `${oneLine(body.watch.name || 'A watch', 120)} fired`, text: `${oneLine(body.watch.name || 'A watch', 120)} fired: ${oneLine(valueText(o.value), 400)}${was}${url}` };++ }++ if (body.event && typeof body.event === 'object' && typeof body.event.type === 'string') {++ const d = body.event.data && typeof body.event.data === 'object' ? body.event.data : {};++ const said = oneLine(d.text || d.summary || d.title || '', LINE_MAX);++ const type = oneLine(body.event.type, 120);++ return { title: said ? `${type}: ${said}` : type, text: said ? `${type}: ${said}` : type };++ }++ const text = typeof body.text === 'string' ? body.text : typeof body.message === 'string' ? body.message : null;++ if (text && oneLine(text, LINE_MAX)) return { title: oneLine(text, 200), text: oneLine(text, LINE_MAX) };++ return null;++}++++/** One delivery: check it, post it, queue it. → { message_id, task_id, repeat } */++async function deliver(id, rawBody, headers, deps = {}) {++ if (!ID_RE.test(String(id || ''))) fail(404, 'hooks.not_found', 'No such hook');++ const hook = await db.get('SELECT * FROM room_hooks WHERE id = ?', [id]);++ if (!hook) fail(404, 'hooks.not_found', 'No such hook');++ const { verifyDeliveryV2 } = require('openvibe-sdk/events');++ if (!verifyDeliveryV2(rawBody, headers, hook.secret, deps.now ? { now: deps.now() } : {})) fail(401, 'hooks.signature', 'The signature is missing, stale or wrong');++ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [hook.room_id]);++ if (!room || !rooms.isWorkspace(room)) fail(404, 'hooks.not_found', 'No such hook');++++ const now = deps.now ? deps.now() : Date.now();++ let r = recent.get(id);++ if (!r || now - r.since > 3600_000) { r = { since: now, n: 0, ids: new Set() }; recent.set(id, r); }++ const deliveryId = oneLine(headers['x-openvibe-delivery-id'] || '', 120);++ if (deliveryId && r.ids.has(deliveryId)) return { message_id: null, task_id: null, repeat: true };++ if (r.n >= PER_HOUR) fail(429, 'hooks.slow_down', `At most ${PER_HOUR} deliveries an hour to one hook`);++++ let body;++ try { body = JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody)); } catch { body = null; }++ const line = lineOf(body);++ if (!line) fail(422, 'hooks.body', 'Send JSON: a Watch firing, an OpenVibe event, or { "text": "…" }');++ r.n += 1;++ if (deliveryId) r.ids.add(deliveryId);++++ const member = (await db.all("SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = 'vibe') DESC, id LIMIT 1", [room.id]))[0];++ let posted = null;++ if (member) {++ posted = await rooms.postAsAi(room, member, `🔔 ${hook.label}: ${line.text}`);++ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }++ }++ let task = null;++ if (hook.queue_kind) {++ const creator = await require('../live-context').getUserById(Number(hook.created_by));++ try {++ task = await require('./workspace').add(room, creator, {++ title: `${hook.label}: ${line.title}`.slice(0, 200), kind: hook.queue_kind, via: 'api',++ details: `From the room's hook "${hook.label}":\n\n${JSON.stringify(body, null, 2).slice(0, 5500)}`,++ }, deps.work || {});++ } catch (err) {++ // The person who made the hook can no longer post here, or the queue is full: the message still stands.++ if (!(err && err.status)) throw err;++ }++ }++ await db.run('UPDATE room_hooks SET deliveries = deliveries + 1, last_at = ov_now() WHERE id = ?', [id]);++ return { message_id: posted ? posted.id : null, task_id: task ? task.id : null, repeat: false };++}++++/** The Express handler for POST /api/chat/hooks/:id (mounted with a raw body parser before the JSON one). */++async function ingress(req, res) {++ try {++ const out = await deliver(req.params.id, Buffer.isBuffer(req.body) ? req.body : Buffer.from(''), req.headers);++ res.status(202).json({ ok: true, ...out });++ } catch (err) {++ if (err instanceof HookError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.warn('[Hooks] delivery failed:', err && err.message);++ res.status(500).json({ error: 'Could not take that right now', code: 'hooks.error' });++ }++}++++function _reset() { recent.clear(); }++++module.exports = { HookError, MAX_PER_ROOM, list, create, remove, deliver, ingress, lineOf, _reset };@@@ -559,6 +559,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">// ── Rooms (server/rooms/) ──const rooms = require('../rooms/rooms');++ const roomHooks = require('../rooms/hooks');const pictures = require('../rooms/pictures');const aiMembers = require('../rooms/ai-members');// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.@@@ -820,6 +821,14 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';${aiSection}`,});}++ const hookList = can.manage && rooms.isWorkspace(room) ? await roomHooks.list(room) : [];++ const hookSection = can.manage && rooms.isWorkspace(room) ? `<h2 id="oc-hooks">Alerts into this room</h2>++<p class="oc-muted">Other systems post here through a hook: an OpenVibe.Watch webhook action, an OpenVibe.Events subscription, or anything that signs its POST the same way. Each one arrives as a message from the room's Vibe, and can add a task to the queue for you. At most ${roomHooks.MAX_PER_ROOM}.</p>++${hookList.length ? `<ul class="oc-members">${hookList.map((h) => `<li><strong>${esc(h.label)}</strong> <span class="oc-muted">${h.queue_kind ? `queues ${esc(h.queue_kind)} work` : 'a message only'} · ${h.deliveries} received${h.last_at ? ` · last ${esc(String(h.last_at).slice(0, 16))}` : ''}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/hooks/${encodeURIComponent(h.id)}/delete"><button type="submit" class="oc-link">Delete</button></form></li>`).join('')}</ul>` : ''}++${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/hooks">++<label>Name <input name="label" required minlength="2" maxlength="60" placeholder="Site errors"></label>++<label>Each alert <select name="queue_kind"><option value="">posts a message</option><option value="research">posts and asks Vibe to look into it (research, runs on your allowance)</option><option value="code">posts and queues a code task for a worker</option><option value="other">posts and queues a task for a worker</option></select></label>++<p><button type="submit">Make a hook</button></p></form>` : ''}` : '';const communityUrl = config.web.communityUrl;const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';await page(req, res, 200, {@@@ -838,6 +847,7 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}<p><button type="submit">Save</button></p></form>` : ''}++${hookSection}<h2>Members</h2><p class="oc-muted">${esc(roleHelp(room.kind))}</p><form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>@@@ -865,6 +875,43 @@ ${attachList}`,res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));++ router.post('/r/:slug/hooks', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/settings`;++ let made;++ try { made = await roomHooks.create(room, actor.user, { label: req.body && req.body.label, queue_kind: (req.body && req.body.queue_kind) || null }); } catch (err) {++ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);++ throw err;++ }++ const address = `${config.web.baseUrl}/api/chat/hooks/${made.hook.id}`;++ res.set('Cache-Control', 'no-store');++ await page(req, res, 200, {++ actor, title: `${room.name}: new hook`, path: req.path, active: 'rooms', robots: 'noindex, nofollow',++ body: `<p><a href="/r/${esc(room.slug)}/settings#oc-hooks">← Room settings</a></p><h1>Hook "${esc(made.hook.label)}" is ready</h1>++${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the address and the secret can post in the room.')}++<label class="oc-form">Address <input readonly value="${esc(address)}"></label>++<label class="oc-form">Secret <input readonly value="${esc(made.secret)}"></label>++<h2>From OpenVibe.Watch</h2>++<ol><li>On openvibe.services, open your project's Secrets page, save the secret (for example as <code>staff_room_hook</code>) and grant it to <code>watch</code>.</li>++<li>On the watch, add a webhook action to the address above, with that secret as its <code>secret_ref</code> (the watch names the project).</li>++<li>Each time the watch fires, Vibe says so here${made.hook.queue_kind ? `, and a ${esc(made.hook.queue_kind)} task joins the queue for you` : ''}.</li></ol>++<h2>From anything else</h2>++<p>POST JSON (<code>{"text": "…"}</code>, or an OpenVibe event <code>{"event": {"type", "data"}}</code>) signed like an OpenVibe.Events delivery: <code>openvibe-sdk/events</code> <code>signDeliveryHeaders(body, secret)</code> gives the headers.</p>`,++ });++ }));++ router.post('/r/:slug/hooks/:id/delete', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');++ try { await roomHooks.remove(room, actor.user, req.params.id); } catch (err) {++ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);++ throw err;++ }++ res.redirect(303, `/r/${room.slug}/settings?saved=1#oc-hooks`);++ }));++router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = await rooms.bySlug(req.params.slug);@@@ -27,7 +27,7 @@ const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events','ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox','chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',];const INDEXES = [nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * Incoming hooks for workspace rooms (server/rooms/hooks.js, migration 0029): only the room's owner (staff, in the staff++ * room) makes one and sees its secret once; a delivery signed like an OpenVibe.Events delivery becomes a message from++ * the room's Vibe (a Watch firing, an OpenVibe event, a line); a missing, wrong or stale signature is 401, an unknown++ * hook 404, a body it cannot read 422; a repeat of a delivery id changes nothing; a hook with a queue kind adds a task++ * for the person who made it; the settings page lists hooks without their secrets.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, suite } = require('./helpers');++++const t = suite('room hooks');++let h, staff, user, rooms, hooks, made;++const deliver = (id, body, secret, headers = {}) => {++ const raw = typeof body === 'string' ? body : JSON.stringify(body);++ return h.http('POST', `/api/chat/hooks/${id}`, { raw, headers: { 'content-type': 'application/json', ...(secret ? signDeliveryHeaders(raw, secret) : {}), ...headers } });++};++const lastMessage = async () => {++ const room = await rooms.bySlug('staff');++ return h.db.getDb().prepare('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);++};++++t('boot', async () => {++ h = await boot();++ rooms = require('../server/rooms/rooms');++ hooks = require('../server/rooms/hooks');++ staff = h.addUser('mia', { role: 'admin', subject: ids.newId('user') });++ user = h.addUser('pat', { subject: ids.newId('user') });++ for (const u of [staff, user]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('the owner makes a hook and sees its secret once; nobody else makes one', async () => {++ const room = await rooms.bySlug('staff');++ const me = await require('../server/live-context').getUserById(staff.id);++ made = await hooks.create(room, me, { label: 'Site errors' });++ assert.match(made.hook.id, /^rh_/);++ assert.match(made.secret, /^whsec_/);++ assert.ok(!JSON.stringify(await hooks.list(room)).includes(made.secret), 'never listed with its secret');++ const pat = await require('../server/live-context').getUserById(user.id);++ await assert.rejects(hooks.create(room, pat, { label: 'Mine' }), (e) => e.status === 404, 'not staff: no such room');++ const cookie = `ov_token=${staff.token}`;++ const r = await h.http('POST', '/r/staff/hooks', { headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, raw: 'label=Deploys&queue_kind=research' });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.text, /whsec_[A-Za-z0-9_-]+/);++ assert.match(r.text, /\/api\/chat\/hooks\/rh_/);++ const page = await h.http('GET', '/r/staff/settings', { headers: { cookie } });++ assert.match(page.text, /Site errors/);++ assert.match(page.text, /Deploys/);++ assert.ok(!/whsec_/.test(page.text), 'the settings page never shows a secret');++});++++t('a signed Watch firing becomes a message from Vibe', async () => {++ const firing = { type: 'watch.watch.triggered', watch: { id: 'wch_1', name: 'Live home p95', url: 'https://openvibe.watch/watches/wch_1' }, check_run_id: 'run_1', observation: { value: 2.4, previous_value: 0.9 } };++ const r = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });++ assert.strictEqual(r.status, 202, r.text);++ const m = await lastMessage();++ assert.ok(m.ai_member_id, 'from the room\'s Vibe');++ assert.strictEqual(m.message, '🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)\nhttps://openvibe.watch/watches/wch_1');++ const again = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });++ assert.strictEqual(again.status, 202);++ assert.strictEqual(again.body.repeat, true);++ assert.strictEqual((await lastMessage()).message, m.message);++});++++t('other bodies: an event, a line; refusals', async () => {++ let r = await deliver(made.hook.id, { event: { type: 'media.vod.ready', data: { title: 'VOD 6100 is ready' } } }, made.secret);++ assert.strictEqual(r.status, 202);++ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: media.vod.ready: VOD 6100 is ready');++ r = await deliver(made.hook.id, { text: 'Backup finished\u0007 in 4 minutes' }, made.secret);++ assert.strictEqual(r.status, 202);++ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: Backup finished in 4 minutes');++ assert.strictEqual((await deliver(made.hook.id, { text: 'no signature' }, null)).status, 401);++ assert.strictEqual((await deliver(made.hook.id, { text: 'wrong key' }, 'whsec_notthesecretnotthesecret12345')).status, 401);++ const raw = JSON.stringify({ text: 'old' });++ const stale = signDeliveryHeaders(raw, made.secret, { now: Date.now() - 20 * 60_000 });++ assert.strictEqual((await h.http('POST', `/api/chat/hooks/${made.hook.id}`, { raw, headers: { 'content-type': 'application/json', ...stale } })).status, 401, 'stale');++ assert.strictEqual((await deliver('rh_nosuchhooknosuchhook123', { text: 'x' }, made.secret)).status, 404);++ assert.strictEqual((await deliver(made.hook.id, { nothing: true }, made.secret)).status, 422);++ assert.strictEqual((await deliver(made.hook.id, 'not json', made.secret)).status, 422);++});++++t('a hook with a queue kind adds a task for the person who made it', async () => {++ const room = await rooms.bySlug('staff');++ const me = await require('../server/live-context').getUserById(staff.id);++ const q = await hooks.create(room, me, { label: 'Broken builds', queue_kind: 'code' });++ const r = await deliver(q.hook.id, { text: 'OpenVibe.Tools main is red' }, q.secret);++ assert.strictEqual(r.status, 202, r.text);++ assert.ok(r.body.task_id);++ const task = h.db.getDb().prepare('SELECT title, kind, via, requested_by, status FROM room_tasks WHERE id = ?');++ const row = await task.get(r.body.task_id);++ assert.deepStrictEqual([row.title, row.kind, row.via, Number(row.requested_by), row.status], ['Broken builds: OpenVibe.Tools main is red', 'code', 'api', staff.id, 'queued']);++ await hooks.remove(room, me, q.hook.id);++ assert.strictEqual((await deliver(q.hook.id, { text: 'gone' }, q.secret)).status, 404, 'a deleted hook is gone');++});++++t('lineOf reads only the forms it knows', () => {++ assert.strictEqual(hooks.lineOf(null), null);++ assert.strictEqual(hooks.lineOf([1]), null);++ assert.strictEqual(hooks.lineOf({ text: ' ' }), null);++ assert.ok(!hooks.lineOf({ type: 'watch.watch.triggered', watch: { name: 'x', url: 'javascript:alert(1)' }, observation: {} }).text.includes('javascript'), 'only https links');++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -405,6 +405,14 @@ member on their first visit, and nobody else reads it, whatever role they once h
@@ -405,6 +405,14 @@ member on their first visit, and nobody else reads it, whatever role they once h
- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page
- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
(`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
sends `room_task`.
sends `room_task`.
+- **Alerts into the room** (`server/rooms/hooks.js`, migration 0029). The room's owner makes up to five hooks on its
+ settings page: an address (`POST /api/chat/hooks/rh_…`) and a secret, shown once. A POST signed like an
+ OpenVibe.Events delivery (X-OpenVibe-Signature-V2 over the raw body, ±5 minutes; an OpenVibe.Watch webhook action with
+ the secret as a project `secret_ref` sends exactly that) becomes a message from the room's Vibe: a Watch firing
+ ("🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)" and the watch's link), an OpenVibe event, or `{ "text" }`. A hook
+ can also queue a task of a chosen kind for the person who made it, while they may still post there. Wrong or stale
+ signature 401, unknown hook 404, unreadable body 422, past 60 an hour 429; a repeated X-OpenVibe-Delivery-Id changes
+ nothing. Hooks are in their maker's export and go when the account does.
- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.
- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.
- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot
- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot
(`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members
(`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- Incoming hooks for workspace rooms (server/rooms/hooks.js; owner 2026-10-25: the shared room "integrated with Watch,
+-- Run and the rest"). A room's owner makes a hook: an address and a secret, shown once. A signed POST to it (signed like
+-- an OpenVibe.Events delivery, as a Watch webhook action signs one) becomes a message from the room's Vibe, and, when
+-- the hook says so, a task in the room's queue for the person who made the hook.
+CREATE TABLE IF NOT EXISTS room_hooks (
+ id text COLLATE "C" PRIMARY KEY, -- rh_<random>: the last part of the hook's address
+ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ label text NOT NULL, -- what the room sees before each message (<= 60)
+ secret text NOT NULL, -- whsec_…: checks X-OpenVibe-Signature-V2 (shown once)
+ queue_kind text COLLATE "C" -- NULL: a message only; else the kind of task each one queues
+ CHECK (queue_kind IS NULL OR queue_kind IN ('answer', 'research', 'web', 'code', 'computer', 'other')),
+ created_by bigint NOT NULL, -- Chat user id: queued tasks are theirs
+ deliveries integer NOT NULL DEFAULT 0,
+ last_at text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS room_hooks_room_idx ON room_hooks (room_id);
+CREATE INDEX IF NOT EXISTS room_hooks_creator_idx ON room_hooks (created_by);
@@ -192,6 +192,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -192,6 +192,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
const openCors = cors({ origin: true, credentials: false, allowedHeaders: ['Content-Type', 'X-Visitor-Token', 'Authorization'] });
const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');
const openPath = (p) => p.startsWith('/api/chat/widget/') || p === '/api/chat/feeds' || p.startsWith('/api/chat/feeds/');
app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));
app.use((req, res, next) => (openPath(req.path) ? openCors : allowlistedCors)(req, res, next));
+ // A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
+ app.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());
app.use(cookieParser());
@@ -61,6 +61,8 @@ const EXPORTS = [
@@ -61,6 +61,8 @@ const EXPORTS = [
['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],
['ai_members_added.json', `SELECT * FROM room_ai_members WHERE created_by_subject = ANY(@s) ORDER BY id DESC LIMIT ${LIMIT}`],
// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.
// Work they put on a workspace room's queue (server/rooms/workspace.js), with its results.
['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],
['room_tasks.json', `SELECT t.id, r.slug AS room, t.title, t.details, t.kind, t.status, t.via, t.result, t.result_url, t.sources, t.error, t.created_at, t.finished_at FROM room_tasks t JOIN rooms r ON r.id = t.room_id WHERE t.requested_subject = ANY(@s) OR t.requested_by = ANY(@ids) ORDER BY t.id DESC LIMIT ${LIMIT}`],
+ // Hooks they made into a workspace room (server/rooms/hooks.js), without their secrets.
+ ['room_hooks.json', `SELECT h.id, r.slug AS room, h.label, h.queue_kind, h.deliveries, h.last_at, h.created_at FROM room_hooks h JOIN rooms r ON r.id = h.room_id WHERE h.created_by = ANY(@ids) ORDER BY h.created_at DESC LIMIT ${LIMIT}`],
['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],
['vibe_usage.json', `SELECT * FROM vibe_usage WHERE user_id = ANY(@ids) ORDER BY day DESC LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
@@ -131,6 +133,8 @@ const ERASE_AFTER_ROOMS = [
@@ -131,6 +133,8 @@ const ERASE_AFTER_ROOMS = [
['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
['feeds', 'DELETE FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
+ // A hook queues work as the person who made it: it goes with them.
+ ['room_hooks', 'DELETE FROM room_hooks WHERE created_by = ANY(@ids)'],
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
// A workspace room's queue keeps the work the team relies on, without who asked or who took it.
[null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
[null, "UPDATE room_tasks SET requested_by = 0, requested_subject = NULL WHERE requested_subject = ANY(@s) OR requested_by = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
+'use strict';
+/**
+ * Incoming hooks for workspace rooms (migrations/0029_room_hooks.sql): what other systems see lands in the room. A room's
+ * owner makes a hook on the room's settings page; its address and secret are shown once.
+ *
+ * POST /api/chat/hooks/:id the raw body, signed like an OpenVibe.Events delivery (X-OpenVibe-Timestamp and
+ * X-OpenVibe-Signature-V2 with the hook's secret, ±5 minutes: openvibe-sdk/events
+ * signDeliveryHeaders makes them, and an OpenVibe.Watch webhook action sends them)
+ *
+ * The body, as JSON, is one of:
+ * a Watch firing { type: 'watch.watch.triggered', watch: { name, url }, observation: { value, previous_value } }
+ * an OpenVibe event { event: { type, data } } (data.text, .summary or .title is the line, if any)
+ * a line { text } (or { message })
+ * It becomes a message from the room's Vibe (its first AI member), "🔔 <label>: …". A hook with a queue kind also adds
+ * a task of that kind to the room's queue for the person who made it (server/rooms/workspace.js), while they may still
+ * post there: quick kinds run at once on their OpenVibe.Actor allowance.
+ *
+ * 202 when it is in the room. 401 for a missing, stale or wrong signature, 404 for no such hook (or a room that is no
+ * longer a workspace), 422 for a body it cannot read, 429 past 60 deliveries an hour per hook. A repeat of the same
+ * X-OpenVibe-Delivery-Id within the hour is answered 202 and changes nothing.
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+const rooms = require('./rooms');
+
+const MAX_PER_ROOM = 5;
+const PER_HOUR = 60;
+const LINE_MAX = 1500;
+const ID_RE = /^rh_[A-Za-z0-9_-]{20,40}$/;
+const KINDS = ['answer', 'research', 'web', 'code', 'computer', 'other'];
+const recent = new Map(); // hook id → { since, n, ids: Set } for the rate limit and repeats, per process
+
+class HookError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
+const fail = (status, code, message) => { throw new HookError(status, code, message); };
+const oneLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+const valueText = (v) => (v == null ? 'no value' : typeof v === 'string' ? v : (() => { try { return JSON.stringify(v); } catch { return String(v); } })());
+
+const shape = (h) => ({ id: h.id, label: h.label, queue_kind: h.queue_kind || null, deliveries: Number(h.deliveries) || 0, last_at: h.last_at || null, created_at: h.created_at });
+
+async function mustManage(room, user) {
+ const a = await rooms.access(room, user);
+ if (!a.read) fail(404, 'rooms.not_found', 'No such room');
+ if (!rooms.isWorkspace(room)) fail(409, 'hooks.not_workspace', 'Only workspace rooms take incoming hooks');
+ if (!a.manage) fail(403, 'hooks.owner_only', 'Only the room\'s owner makes hooks');
+ return a;
+}
+
+async function list(room) {
+ return (await db.all('SELECT * FROM room_hooks WHERE room_id = ? ORDER BY created_at, id', [room.id])).map(shape);
+}
+
+/** A new hook. → { hook, secret }: the secret is never shown again. */
+async function create(room, user, { label, queue_kind = null } = {}) {
+ await mustManage(room, user);
+ const name = oneLine(label, 60);
+ if (name.length < 2) fail(422, 'hooks.label', 'Give the hook a short name, such as "Site errors"');
+ const kind = KINDS.includes(queue_kind) ? queue_kind : null;
+ const n = await db.get('SELECT COUNT(*) AS n FROM room_hooks WHERE room_id = ?', [room.id]);
+ if (Number(n && n.n) >= MAX_PER_ROOM) fail(429, 'hooks.too_many', `At most ${MAX_PER_ROOM} hooks a room`);
+ const id = `rh_${crypto.randomBytes(18).toString('base64url')}`;
+ const secret = `whsec_${crypto.randomBytes(24).toString('base64url')}`;
+ const row = await db.get('INSERT INTO room_hooks (id, room_id, label, secret, queue_kind, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *', [id, room.id, name, secret, kind, user.id]);
+ return { hook: shape(row), secret };
+}
+
+async function remove(room, user, id) {
+ await mustManage(room, user);
+ await db.run('DELETE FROM room_hooks WHERE room_id = ? AND id = ?', [room.id, String(id || '')]);
+}
+
+/** The room's line for a body, or null when the body is none of the forms above. */
+function lineOf(body) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
+ if (body.type === 'watch.watch.triggered' && body.watch && typeof body.watch === 'object') {
+ const o = body.observation || {};
+ const was = o.previous_value === undefined || o.previous_value === null ? '' : ` (was ${oneLine(valueText(o.previous_value), 200)})`;
+ const url = /^https:\/\//.test(String(body.watch.url || '')) ? `\n${oneLine(body.watch.url, 500)}` : '';
+ return { title: `${oneLine(body.watch.name || 'A watch', 120)} fired`, text: `${oneLine(body.watch.name || 'A watch', 120)} fired: ${oneLine(valueText(o.value), 400)}${was}${url}` };
+ }
+ if (body.event && typeof body.event === 'object' && typeof body.event.type === 'string') {
+ const d = body.event.data && typeof body.event.data === 'object' ? body.event.data : {};
+ const said = oneLine(d.text || d.summary || d.title || '', LINE_MAX);
+ const type = oneLine(body.event.type, 120);
+ return { title: said ? `${type}: ${said}` : type, text: said ? `${type}: ${said}` : type };
+ }
+ const text = typeof body.text === 'string' ? body.text : typeof body.message === 'string' ? body.message : null;
+ if (text && oneLine(text, LINE_MAX)) return { title: oneLine(text, 200), text: oneLine(text, LINE_MAX) };
+ return null;
+}
+
+/** One delivery: check it, post it, queue it. → { message_id, task_id, repeat } */
+async function deliver(id, rawBody, headers, deps = {}) {
+ if (!ID_RE.test(String(id || ''))) fail(404, 'hooks.not_found', 'No such hook');
+ const hook = await db.get('SELECT * FROM room_hooks WHERE id = ?', [id]);
+ if (!hook) fail(404, 'hooks.not_found', 'No such hook');
+ const { verifyDeliveryV2 } = require('openvibe-sdk/events');
+ if (!verifyDeliveryV2(rawBody, headers, hook.secret, deps.now ? { now: deps.now() } : {})) fail(401, 'hooks.signature', 'The signature is missing, stale or wrong');
+ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [hook.room_id]);
+ if (!room || !rooms.isWorkspace(room)) fail(404, 'hooks.not_found', 'No such hook');
+
+ const now = deps.now ? deps.now() : Date.now();
+ let r = recent.get(id);
+ if (!r || now - r.since > 3600_000) { r = { since: now, n: 0, ids: new Set() }; recent.set(id, r); }
+ const deliveryId = oneLine(headers['x-openvibe-delivery-id'] || '', 120);
+ if (deliveryId && r.ids.has(deliveryId)) return { message_id: null, task_id: null, repeat: true };
+ if (r.n >= PER_HOUR) fail(429, 'hooks.slow_down', `At most ${PER_HOUR} deliveries an hour to one hook`);
+
+ let body;
+ try { body = JSON.parse(Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : String(rawBody)); } catch { body = null; }
+ const line = lineOf(body);
+ if (!line) fail(422, 'hooks.body', 'Send JSON: a Watch firing, an OpenVibe event, or { "text": "…" }');
+ r.n += 1;
+ if (deliveryId) r.ids.add(deliveryId);
+
+ const member = (await db.all("SELECT * FROM room_ai_members WHERE room_id = ? AND enabled = 1 ORDER BY (handle = 'vibe') DESC, id LIMIT 1", [room.id]))[0];
+ let posted = null;
+ if (member) {
+ posted = await rooms.postAsAi(room, member, `🔔 ${hook.label}: ${line.text}`);
+ try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* sockets are optional */ }
+ }
+ let task = null;
+ if (hook.queue_kind) {
+ const creator = await require('../live-context').getUserById(Number(hook.created_by));
+ try {
+ task = await require('./workspace').add(room, creator, {
+ title: `${hook.label}: ${line.title}`.slice(0, 200), kind: hook.queue_kind, via: 'api',
+ details: `From the room's hook "${hook.label}":\n\n${JSON.stringify(body, null, 2).slice(0, 5500)}`,
+ }, deps.work || {});
+ } catch (err) {
+ // The person who made the hook can no longer post here, or the queue is full: the message still stands.
+ if (!(err && err.status)) throw err;
+ }
+ }
+ await db.run('UPDATE room_hooks SET deliveries = deliveries + 1, last_at = ov_now() WHERE id = ?', [id]);
+ return { message_id: posted ? posted.id : null, task_id: task ? task.id : null, repeat: false };
+}
+
+/** The Express handler for POST /api/chat/hooks/:id (mounted with a raw body parser before the JSON one). */
+async function ingress(req, res) {
+ try {
+ const out = await deliver(req.params.id, Buffer.isBuffer(req.body) ? req.body : Buffer.from(''), req.headers);
+ res.status(202).json({ ok: true, ...out });
+ } catch (err) {
+ if (err instanceof HookError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.warn('[Hooks] delivery failed:', err && err.message);
+ res.status(500).json({ error: 'Could not take that right now', code: 'hooks.error' });
+ }
+}
+
+function _reset() { recent.clear(); }
+
+module.exports = { HookError, MAX_PER_ROOM, list, create, remove, deliver, ingress, lineOf, _reset };
@@ -559,6 +559,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
@@ -559,6 +559,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
// ── Rooms (server/rooms/) ──
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
+ const roomHooks = require('../rooms/hooks');
const pictures = require('../rooms/pictures');
const pictures = require('../rooms/pictures');
const aiMembers = require('../rooms/ai-members');
const aiMembers = require('../rooms/ai-members');
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
@@ -820,6 +821,14 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
@@ -820,6 +821,14 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
${aiSection}`,
${aiSection}`,
});
});
}
}
+ const hookList = can.manage && rooms.isWorkspace(room) ? await roomHooks.list(room) : [];
+ const hookSection = can.manage && rooms.isWorkspace(room) ? `<h2 id="oc-hooks">Alerts into this room</h2>
+<p class="oc-muted">Other systems post here through a hook: an OpenVibe.Watch webhook action, an OpenVibe.Events subscription, or anything that signs its POST the same way. Each one arrives as a message from the room's Vibe, and can add a task to the queue for you. At most ${roomHooks.MAX_PER_ROOM}.</p>
+${hookList.length ? `<ul class="oc-members">${hookList.map((h) => `<li><strong>${esc(h.label)}</strong> <span class="oc-muted">${h.queue_kind ? `queues ${esc(h.queue_kind)} work` : 'a message only'} · ${h.deliveries} received${h.last_at ? ` · last ${esc(String(h.last_at).slice(0, 16))}` : ''}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/hooks/${encodeURIComponent(h.id)}/delete"><button type="submit" class="oc-link">Delete</button></form></li>`).join('')}</ul>` : ''}
+${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/hooks">
+<label>Name <input name="label" required minlength="2" maxlength="60" placeholder="Site errors"></label>
+<label>Each alert <select name="queue_kind"><option value="">posts a message</option><option value="research">posts and asks Vibe to look into it (research, runs on your allowance)</option><option value="code">posts and queues a code task for a worker</option><option value="other">posts and queues a task for a worker</option></select></label>
+<p><button type="submit">Make a hook</button></p></form>` : ''}` : '';
const communityUrl = config.web.communityUrl;
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
await page(req, res, 200, {
@@ -838,6 +847,7 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
@@ -838,6 +847,7 @@ ${room.kind === 'call' ? `<label>People who join <select name="join_role">${room
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
+${hookSection}
<h2>Members</h2>
<h2>Members</h2>
<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
<p class="oc-muted">${esc(roleHelp(room.kind))}</p>
<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"> <select name="role" aria-label="Their role">${choices.filter((r) => r !== 'none' && r !== 'blocked').map((r) => roleOption(r, addRole)).join('')}</select> <button type="submit">Add</button></form>
@@ -865,6 +875,43 @@ ${attachList}`,
@@ -865,6 +875,43 @@ ${attachList}`,
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
+ router.post('/r/:slug/hooks', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/settings`;
+ let made;
+ try { made = await roomHooks.create(room, actor.user, { label: req.body && req.body.label, queue_kind: (req.body && req.body.queue_kind) || null }); } catch (err) {
+ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);
+ throw err;
+ }
+ const address = `${config.web.baseUrl}/api/chat/hooks/${made.hook.id}`;
+ res.set('Cache-Control', 'no-store');
+ await page(req, res, 200, {
+ actor, title: `${room.name}: new hook`, path: req.path, active: 'rooms', robots: 'noindex, nofollow',
+ body: `<p><a href="/r/${esc(room.slug)}/settings#oc-hooks">← Room settings</a></p><h1>Hook "${esc(made.hook.label)}" is ready</h1>
+${notice('ok', 'Copy the secret now: it is not shown again. Anyone with the address and the secret can post in the room.')}
+<label class="oc-form">Address <input readonly value="${esc(address)}"></label>
+<label class="oc-form">Secret <input readonly value="${esc(made.secret)}"></label>
+<h2>From OpenVibe.Watch</h2>
+<ol><li>On openvibe.services, open your project's Secrets page, save the secret (for example as <code>staff_room_hook</code>) and grant it to <code>watch</code>.</li>
+<li>On the watch, add a webhook action to the address above, with that secret as its <code>secret_ref</code> (the watch names the project).</li>
+<li>Each time the watch fires, Vibe says so here${made.hook.queue_kind ? `, and a ${esc(made.hook.queue_kind)} task joins the queue for you` : ''}.</li></ol>
+<h2>From anything else</h2>
+<p>POST JSON (<code>{"text": "…"}</code>, or an OpenVibe event <code>{"event": {"type", "data"}}</code>) signed like an OpenVibe.Events delivery: <code>openvibe-sdk/events</code> <code>signDeliveryHeaders(body, secret)</code> gives the headers.</p>`,
+ });
+ }));
+ router.post('/r/:slug/hooks/:id/delete', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
+ try { await roomHooks.remove(room, actor.user, req.params.id); } catch (err) {
+ if (err instanceof roomHooks.HookError) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(err.message)}#oc-hooks`);
+ throw err;
+ }
+ res.redirect(303, `/r/${room.slug}/settings?saved=1#oc-hooks`);
+ }));
+
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
const room = await rooms.bySlug(req.params.slug);
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
+ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
];
const INDEXES = [
const INDEXES = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * Incoming hooks for workspace rooms (server/rooms/hooks.js, migration 0029): only the room's owner (staff, in the staff
+ * room) makes one and sees its secret once; a delivery signed like an OpenVibe.Events delivery becomes a message from
+ * the room's Vibe (a Watch firing, an OpenVibe event, a line); a missing, wrong or stale signature is 401, an unknown
+ * hook 404, a body it cannot read 422; a repeat of a delivery id changes nothing; a hook with a queue kind adds a task
+ * for the person who made it; the settings page lists hooks without their secrets.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, suite } = require('./helpers');
+
+const t = suite('room hooks');
+let h, staff, user, rooms, hooks, made;
+const deliver = (id, body, secret, headers = {}) => {
+ const raw = typeof body === 'string' ? body : JSON.stringify(body);
+ return h.http('POST', `/api/chat/hooks/${id}`, { raw, headers: { 'content-type': 'application/json', ...(secret ? signDeliveryHeaders(raw, secret) : {}), ...headers } });
+};
+const lastMessage = async () => {
+ const room = await rooms.bySlug('staff');
+ return h.db.getDb().prepare('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
+};
+
+t('boot', async () => {
+ h = await boot();
+ rooms = require('../server/rooms/rooms');
+ hooks = require('../server/rooms/hooks');
+ staff = h.addUser('mia', { role: 'admin', subject: ids.newId('user') });
+ user = h.addUser('pat', { subject: ids.newId('user') });
+ for (const u of [staff, user]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('the owner makes a hook and sees its secret once; nobody else makes one', async () => {
+ const room = await rooms.bySlug('staff');
+ const me = await require('../server/live-context').getUserById(staff.id);
+ made = await hooks.create(room, me, { label: 'Site errors' });
+ assert.match(made.hook.id, /^rh_/);
+ assert.match(made.secret, /^whsec_/);
+ assert.ok(!JSON.stringify(await hooks.list(room)).includes(made.secret), 'never listed with its secret');
+ const pat = await require('../server/live-context').getUserById(user.id);
+ await assert.rejects(hooks.create(room, pat, { label: 'Mine' }), (e) => e.status === 404, 'not staff: no such room');
+ const cookie = `ov_token=${staff.token}`;
+ const r = await h.http('POST', '/r/staff/hooks', { headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, raw: 'label=Deploys&queue_kind=research' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.text, /whsec_[A-Za-z0-9_-]+/);
+ assert.match(r.text, /\/api\/chat\/hooks\/rh_/);
+ const page = await h.http('GET', '/r/staff/settings', { headers: { cookie } });
+ assert.match(page.text, /Site errors/);
+ assert.match(page.text, /Deploys/);
+ assert.ok(!/whsec_/.test(page.text), 'the settings page never shows a secret');
+});
+
+t('a signed Watch firing becomes a message from Vibe', async () => {
+ const firing = { type: 'watch.watch.triggered', watch: { id: 'wch_1', name: 'Live home p95', url: 'https://openvibe.watch/watches/wch_1' }, check_run_id: 'run_1', observation: { value: 2.4, previous_value: 0.9 } };
+ const r = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });
+ assert.strictEqual(r.status, 202, r.text);
+ const m = await lastMessage();
+ assert.ok(m.ai_member_id, 'from the room\'s Vibe');
+ assert.strictEqual(m.message, '🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)\nhttps://openvibe.watch/watches/wch_1');
+ const again = await deliver(made.hook.id, firing, made.secret, { 'x-openvibe-delivery-id': 'run_1.0' });
+ assert.strictEqual(again.status, 202);
+ assert.strictEqual(again.body.repeat, true);
+ assert.strictEqual((await lastMessage()).message, m.message);
+});
+
+t('other bodies: an event, a line; refusals', async () => {
+ let r = await deliver(made.hook.id, { event: { type: 'media.vod.ready', data: { title: 'VOD 6100 is ready' } } }, made.secret);
+ assert.strictEqual(r.status, 202);
+ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: media.vod.ready: VOD 6100 is ready');
+ r = await deliver(made.hook.id, { text: 'Backup finished\u0007 in 4 minutes' }, made.secret);
+ assert.strictEqual(r.status, 202);
+ assert.strictEqual((await lastMessage()).message, '🔔 Site errors: Backup finished in 4 minutes');
+ assert.strictEqual((await deliver(made.hook.id, { text: 'no signature' }, null)).status, 401);
+ assert.strictEqual((await deliver(made.hook.id, { text: 'wrong key' }, 'whsec_notthesecretnotthesecret12345')).status, 401);
+ const raw = JSON.stringify({ text: 'old' });
+ const stale = signDeliveryHeaders(raw, made.secret, { now: Date.now() - 20 * 60_000 });
+ assert.strictEqual((await h.http('POST', `/api/chat/hooks/${made.hook.id}`, { raw, headers: { 'content-type': 'application/json', ...stale } })).status, 401, 'stale');
+ assert.strictEqual((await deliver('rh_nosuchhooknosuchhook123', { text: 'x' }, made.secret)).status, 404);
+ assert.strictEqual((await deliver(made.hook.id, { nothing: true }, made.secret)).status, 422);
+ assert.strictEqual((await deliver(made.hook.id, 'not json', made.secret)).status, 422);
+});
+
+t('a hook with a queue kind adds a task for the person who made it', async () => {
+ const room = await rooms.bySlug('staff');
+ const me = await require('../server/live-context').getUserById(staff.id);
+ const q = await hooks.create(room, me, { label: 'Broken builds', queue_kind: 'code' });
+ const r = await deliver(q.hook.id, { text: 'OpenVibe.Tools main is red' }, q.secret);
+ assert.strictEqual(r.status, 202, r.text);
+ assert.ok(r.body.task_id);
+ const task = h.db.getDb().prepare('SELECT title, kind, via, requested_by, status FROM room_tasks WHERE id = ?');
+ const row = await task.get(r.body.task_id);
+ assert.deepStrictEqual([row.title, row.kind, row.via, Number(row.requested_by), row.status], ['Broken builds: OpenVibe.Tools main is red', 'code', 'api', staff.id, 'queued']);
+ await hooks.remove(room, me, q.hook.id);
+ assert.strictEqual((await deliver(q.hook.id, { text: 'gone' }, q.secret)).status, 404, 'a deleted hook is gone');
+});
+
+t('lineOf reads only the forms it knows', () => {
+ assert.strictEqual(hooks.lineOf(null), null);
+ assert.strictEqual(hooks.lineOf([1]), null);
+ assert.strictEqual(hooks.lineOf({ text: ' ' }), null);
+ assert.ok(!hooks.lineOf({ type: 'watch.watch.triggered', watch: { name: 'x', url: 'javascript:alert(1)' }, observation: {} }).text.includes('javascript'), 'only https links');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });