openvibers/openvibe.chat Commits

Vibe answers through other services: by voice (chat.vibe.ask) and agent runs delivered to Vibe chats (chat.vibe.deliver) (#178)

OP OpenVibers committed 19 days ago · ec339f659f · 9 files, +443 −2

* Vibe answers a person through another service (chat.vibe.ask, POST /internal/chat/vibe/ask): Bot's Vibe by voice, kept in their own "Voice on <machine>" chat, answered as plain text to speak, once per idempotency key

* Each run is a chat you can open: POST /internal/chat/vibe/deliver (chat.vibe.deliver) posts an agent's run into the person's Vibe chat named after it, as Vibe with a link to the whole run, once per key; no AI answer, nothing counted

Changes

ddiff --git a/migrations/0033_vibe_asks.sql b/migrations/0033_vibe_asks.sql
nnew file mode 100644
iindex 0000000..b9238c4
---- /dev/null
++++ b/migrations/0033_vibe_asks.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- Vibe answers a person through another OpenVibe service (capability chat.vibe.ask, server/chat/internal-vibe.js):
++-- OpenVibe.Bot's Vibe by voice. Each source (a machine) has the person's own Vibe chat, "Voice on <machine>", marked by
++-- rooms.assistant_source (kind:id); deleting the chat removes the mark with it, and the next question starts a new one.
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS assistant_source text COLLATE "C";
++CREATE UNIQUE INDEX IF NOT EXISTS rooms_assistant_source ON rooms (owner_id, assistant_source) WHERE assistant_source IS NOT NULL;
++-- One answer per caller's idempotency key (a retried voice_ask gets the first answer, once), kept 7 days.
++CREATE TABLE IF NOT EXISTS vibe_asks (
++ idempotency_key text COLLATE "C" PRIMARY KEY,
++ caller text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL,
++ room_id bigint,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('answered', 'refused')),
++ answer text NOT NULL DEFAULT '',
++ code text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS vibe_asks_by_user ON vibe_asks (user_id);
++CREATE INDEX IF NOT EXISTS vibe_asks_created ON vibe_asks (created_at);
ddiff --git a/migrations/0034_vibe_deliveries.sql b/migrations/0034_vibe_deliveries.sql
nnew file mode 100644
iindex 0000000..95225b0
---- /dev/null
++++ b/migrations/0034_vibe_deliveries.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- A first-party service's results in a person's own Vibe chats (capability chat.vibe.deliver; server/chat/internal-vibe.js):
++-- OpenVibe.Actor posts each passed run of one of their agents into the chat named after it (rooms.assistant_source
++-- 'agent:<id>', migrations/0033_vibe_asks.sql). One post per caller's idempotency key (the run's task id), kept 30 days.
++CREATE TABLE IF NOT EXISTS vibe_deliveries (
++ idempotency_key text COLLATE "C" PRIMARY KEY,
++ caller text COLLATE "C" NOT NULL,
++ user_id bigint NOT NULL,
++ room_id bigint NOT NULL,
++ message_id bigint,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS vibe_deliveries_by_user ON vibe_deliveries (user_id);
++CREATE INDEX IF NOT EXISTS vibe_deliveries_created ON vibe_deliveries (created_at);
ddiff --git a/server/app.js b/server/app.js
iindex dbd9cf0..fde4cc3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -95,6 +95,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/chat', express.json({ limit: '64kb' }), require('./chat/internal-reads'));
// OpenVibe.Help's per-ticket conversation (capability chat.ticket.write, server/chat/internal-tickets.js).
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'));
// 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 59ba552..a6cfa35 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -67,6 +67,10 @@ const EXPORTS = [
// What Vibe remembers about them (server/rooms/vibe-memory.js), and whether they switched it off.
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
++ // What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).
++ // Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
++ ['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
++ ['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_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_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@@ -117,6 +121,8 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
++ ['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
++ ['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
ddiff --git a/server/chat/internal-vibe.js b/server/chat/internal-vibe.js
nnew file mode 100644
iindex 0000000..3ff478f
---- /dev/null
++++ b/server/chat/internal-vibe.js
@@@ -0,0 +1,198 @@
++'use strict';
++/**
++ * Vibe answers a person through another OpenVibe service (capability chat.vibe.ask; openvibe-contracts 0.214.0
++ * chat.vibe-ask-request@1 / chat.vibe-ask-result@1). OpenVibe.Bot's Vibe by voice hands over what a person said after
++ * their machine's call word, with the machine's owner as the subject (Bot checks the owner turned voice on for it).
++ * Loopback only, a service token for audience openvibe.chat.
++ *
++ * POST /internal/chat/vibe/ask { subject, text, source: { kind: 'voice', id, name? }, lang?, idempotency_key? }
++ * → 200 { state: 'answered', text, url, chat } | { state: 'refused', text, code }
++ *
++ * The question and the answer are kept in the person's own Vibe chats: one per source, "Voice on <name>", made on first
++ * use and marked by rooms.assistant_source (migrations/0033_vibe_asks.sql; deleting the chat removes the mark, and the
++ * next question starts a new one). The answer counts against the person's daily allowance like any of theirs, and comes
++ * back as plain text to speak (Markdown taken off). The same idempotency key from the same caller answers once: asked
++ * again, the first answer (kept 7 days). A person Chat has not seen, Vibe resting or the allowance spent: refused, with why.
++ *
++ * POST /internal/chat/vibe/deliver { subject, source: { kind: 'agent', id, name? }, text, url?, idempotency_key }
++ * → 201 { chat, url, repeated: false } | 200 { chat, url, repeated: true }
++ *
++ * Each run is a chat you can open (capability chat.vibe.deliver; chat.vibe-deliver-request@1 / -result@1): OpenVibe.Actor
++ * posts each passed run of one of the person's agents, as Vibe's message, into their Vibe chat named after the agent
++ * (one per agent, marked like the voice chats), with a link to the whole run. No AI answer is made and nothing counts
++ * against their allowance; they can reply in the chat and Vibe answers knowing the result. One post per caller's key
++ * (vibe_deliveries, kept 30 days). A person Chat has not seen is 422. They get one OpenVibe notification per post
++ * ("<agent> has a new result", category chat, which they can switch off on the Network), sent after the answer.
++ */
++const express = require('express');
++const config = require('../config');
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++const vibe = require('../rooms/vibe');
++const rooms = require('../rooms/rooms');
++const aiMembers = require('../rooms/ai-members');
++
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SOURCE_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
++const KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
++const KEEP_MS = 7 * 24 * 3600 * 1000;
++const fail = (status, message) => { const e = new Error(message); e.status = status; throw e; };
++const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
++
++/** The request's fields, checked as chat.vibe-ask-request@1 says (Chat's own check until it pins the schema). */
++function requestOf(b) {
++ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');
++ if (Object.keys(b).some((k) => !['subject', 'text', 'source', 'lang', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');
++ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');
++ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';
++ if (!text || text.length > 2000) fail(400, 'text is 1 to 2000 characters');
++ const s = b.source;
++ if (!s || typeof s !== 'object' || s.kind !== 'voice' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: voice, id, name? }');
++ if (b.idempotency_key != null && !KEY.test(String(b.idempotency_key))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');
++ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';
++ return { subject: b.subject, text, source: { kind: 'voice', id: s.id, name }, key: b.idempotency_key || null };
++}
++
++/** Markdown off, for a voice to read: links keep their text, code and emphasis marks go, lists become sentences. */
++function spoken(md) {
++ return String(md || '')
++ .replace(/```[\s\S]*?```/g, ' ')
++ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
++ .replace(/\[([^\]]+)\]\((?:https?:)?[^)]*\)/g, '$1')
++ .replace(/`([^`]*)`/g, '$1')
++ .replace(/(\*\*|__|\*|_|~~)(\S[\s\S]*?\S|\S)\1/g, '$2')
++ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
++ .replace(/^\s*[-*+]\s+/gm, '')
++ .replace(/^\s*>\s?/gm, '')
++ .replace(/https?:\/\/\S+/g, '')
++ .replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n').trim()
++ .slice(0, 4000);
++}
++
++/** The person's chat for this source: the marked one when it is still theirs, else a new one ("Voice on <name>", or the agent's name). */
++async function chatFor(user, source) {
++ const mark = `${source.kind}:${source.id}`;
++ const room = await db.get('SELECT * FROM rooms WHERE owner_id = ? AND assistant = 1 AND assistant_source = ? AND archived_at IS NULL', [user.id, mark]);
++ if (room) return room;
++ const made = await vibe.createRoom(user, source.kind === 'agent' ? (source.name || 'Your agent') : `Voice on ${source.name || 'your computer'}`);
++ await db.run('UPDATE rooms SET assistant_source = ? WHERE id = ?', [mark, made.id]);
++ return { ...made, assistant_source: mark };
++}
++
++async function ask(req) {
++ const r = requestOf(req.body);
++ const caller = String((req.principal && req.principal.sub) || '');
++ const key = r.key ? `${caller}|${r.key}` : null;
++ if (key) {
++ const seen = await db.get('SELECT * FROM vibe_asks WHERE idempotency_key = ?', [key]);
++ if (seen) return seen.state === 'answered' ? answered(seen.answer, seen.room_id) : refused(seen.answer, seen.code);
++ }
++ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [r.subject]);
++ let out;
++ let roomId = null;
++ if (!user || Number(user.is_banned)) out = refused('This account cannot use Vibe yet: sign in on openvibe.chat once first.', 'chat.vibe.unknown_person');
++ else if (!vibe.enabled()) out = refused('Vibe is resting right now. Try again later.', 'chat.vibe.off');
++ else if (await vibe.repliesToday(user) >= config.ai.vibeRepliesPerDay) out = refused(`You have used today's ${config.ai.vibeRepliesPerDay} free answers. They come back at midnight UTC.`, 'chat.vibe.allowance');
++ else {
++ const room = await chatFor(user, r.source);
++ roomId = Number(room.id);
++ const message = await rooms.post(room, user, r.text, { ai: false });
++ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }
++ const reply = await aiMembers.afterPost(room, message, user);
++ out = reply && reply.message ? answered(spoken(reply.message), roomId, room.slug) : refused('Vibe could not answer just now. Try again in a minute.', 'chat.vibe.unavailable');
++ }
++ if (key) {
++ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_asks WHERE created_at < ?', [sqlTime(Date.now() - KEEP_MS)]);
++ await db.run(`INSERT INTO vibe_asks (idempotency_key, caller, user_id, room_id, state, answer, code) VALUES (?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (idempotency_key) DO NOTHING`, [key, caller, user ? user.id : 0, roomId, out.state, out.text, out.code || null]);
++ }
++ return out;
++}
++
++function answered(text, roomId, slug = null) {
++ return { state: 'answered', text: String(text || '').slice(0, 4000), ...(slug ? { url: `${config.web.baseUrl}/vibe/${slug}`, chat: slug } : {}), ...(roomId && !slug ? { room_id: roomId } : {}) };
++}
++const refused = (text, code) => ({ state: 'refused', text, code });
++
++/** chat.vibe-deliver-request@1, checked as the schema says. */
++function deliveryOf(b) {
++ if (!b || typeof b !== 'object' || Array.isArray(b)) fail(400, 'A JSON object is required');
++ if (Object.keys(b).some((k) => !['subject', 'source', 'text', 'url', 'idempotency_key'].includes(k))) fail(400, 'Invalid fields');
++ if (!SUBJECT.test(String(b.subject || ''))) fail(400, 'subject is a person (usr_…)');
++ const s = b.source;
++ if (!s || typeof s !== 'object' || s.kind !== 'agent' || !SOURCE_ID.test(String(s.id || '')) || Object.keys(s).some((k) => !['kind', 'id', 'name'].includes(k))) fail(400, 'source is { kind: agent, id, name? }');
++ const text = typeof b.text === 'string' ? b.text.replace(/\r\n?/g, '\n').trim() : '';
++ if (!text || text.length > 8000) fail(400, 'text is 1 to 8000 characters');
++ let url = null;
++ if (b.url != null) {
++ try { const u = new URL(String(b.url)); if (u.protocol === 'https:') url = u.href; } catch { /* checked below */ }
++ if (!url) fail(400, 'url is an https address');
++ }
++ if (!KEY.test(String(b.idempotency_key || ''))) fail(400, 'idempotency_key is 1 to 128 of A-Z a-z 0-9 _ . : -');
++ const name = typeof s.name === 'string' ? s.name.replace(/\s+/g, ' ').trim().slice(0, 40) : '';
++ return { subject: b.subject, source: { kind: 'agent', id: s.id, name }, text, url, key: b.idempotency_key };
++}
++
++const chatUrl = (slug) => `${config.web.baseUrl}/vibe/${slug}`;
++
++/** "<agent> has a new result", with its first line, to the chat (never holds up the answer to the caller). */
++async function notifyResult(user, room, d) {
++ if (!user.subject_id || (!config.oauth.clientSecret && !require('../inbox/notify')._hasTestPush())) return;
++ const name = d.source.name || 'Your agent';
++ await require('../inbox/notify').pushOne({
++ subjectId: user.subject_id, type: 'AGENT_RESULT', category: 'chat', service: 'chat', icon: '🤖', sender_name: name, priority: 'normal',
++ title: `${name} has a new result`, message: spoken(d.text).split('\n')[0].slice(0, 160) || 'Open the chat to read it.', url: chatUrl(room.slug),
++ });
++}
++async function deliver(req) {
++ const d = deliveryOf(req.body);
++ const caller = String((req.principal && req.principal.sub) || '');
++ const key = `${caller}|${d.key}`;
++ const seen = await db.get('SELECT d.room_id, r.slug FROM vibe_deliveries d LEFT JOIN rooms r ON r.id = d.room_id WHERE d.idempotency_key = ?', [key]);
++ if (seen && seen.slug) return { status: 200, body: { chat: seen.slug, url: chatUrl(seen.slug), repeated: true } };
++ const user = await db.get('SELECT * FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1', [d.subject]);
++ if (!user || Number(user.is_banned)) fail(422, 'This account has no Vibe chats yet: they sign in on openvibe.chat once first');
++ const room = await chatFor(user, d.source);
++ const member = await db.get("SELECT * FROM room_ai_members WHERE room_id = ? AND handle = 'vibe'", [room.id]);
++ if (!member) fail(409, 'That Vibe chat has no Vibe in it');
++ const link = d.url ? `\n\n[See the whole run on OpenVibe Agents](${d.url})` : '';
++ const message = await rooms.postAsAi(room, member, `${d.text.slice(0, 8000 - link.length)}${link}`);
++ try { require('./chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message }); } catch { /* sockets are optional */ }
++ if (Math.random() < 0.05) await db.run('DELETE FROM vibe_deliveries WHERE created_at < ?', [sqlTime(Date.now() - 30 * 24 * 3600 * 1000)]);
++ await db.run('INSERT INTO vibe_deliveries (idempotency_key, caller, user_id, room_id, message_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT (idempotency_key) DO NOTHING',
++ [key, caller, user.id, room.id, message ? message.id : null]);
++ notifyResult(user, room, d).catch((err) => console.warn(`[Vibe deliver] notify: ${err && err.message}`));
++ return { status: 201, body: { chat: room.slug, url: chatUrl(room.slug), repeated: false } };
++}
++
++const router = express.Router();
++router.post('/vibe/deliver', serviceAuth.guard('chat.vibe.deliver'), async (req, res) => {
++ try {
++ const out = await deliver(req);
++ res.status(out.status).json(out.body);
++ } catch (err) {
++ const status = err && err.status && err.status < 500 ? err.status : 500;
++ if (status >= 500) console.error('[Vibe deliver]', err && err.message);
++ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });
++ }
++});
++
++router.post('/vibe/ask', serviceAuth.guard('chat.vibe.ask'), async (req, res) => {
++ try {
++ const out = await ask(req);
++ // A replayed answer names its chat by id only: look the slug up so the url is there too.
++ if (out.room_id) {
++ const room = await db.get('SELECT slug FROM rooms WHERE id = ?', [out.room_id]);
++ delete out.room_id;
++ if (room) Object.assign(out, { url: `${config.web.baseUrl}/vibe/${room.slug}`, chat: room.slug });
++ }
++ res.json(out);
++ } catch (err) {
++ const status = err && err.status && err.status < 500 ? err.status : (err && err.code && String(err.code).startsWith('rooms.') ? 422 : 500);
++ if (status >= 500) console.error('[Vibe ask]', err && err.message);
++ res.status(status).json({ error: status >= 500 ? 'Something went wrong' : err.message, code: err && err.code });
++ }
++});
++
++module.exports = router;
++module.exports.spoken = spoken;
ddiff --git a/server/inbox/notify.js b/server/inbox/notify.js
iindex 87245ae..5695661 100644
---- a/server/inbox/notify.js
++++ b/server/inbox/notify.js
@@@ -78,4 +78,8 @@ function later(conv, reason) { people(conv, reason).catch((err) => console.warn(
/** Tests: capture pushes instead of calling the Network (null restores it). */
function _setPush(fn) { _testPush = fn; lastSent.clear(); }
--module.exports = { people, later, QUIET_MS, _setPush };
++/** One notification to one person (subjectId): the push itself, for other Chat features (internal-vibe.js deliver). */
++const pushOne = (notification) => (_testPush || push)(notification);
++const _hasTestPush = () => !!_testPush;
++
++module.exports = { people, later, pushOne, QUIET_MS, _setPush, _hasTestPush };
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 0f8630b..972db3b 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', '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', 'vibe_memory', 'vibe_memory_off', 'vibe_asks', 'vibe_deliveries', '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 = [
ddiff --git a/test/vibe-ask.test.js b/test/vibe-ask.test.js
nnew file mode 100644
iindex 0000000..cae74ff
---- /dev/null
++++ b/test/vibe-ask.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * Vibe answers a person through another service (server/chat/internal-vibe.js, capability chat.vibe.ask, contracts
++ * 0.214.0): Bot's Vibe by voice. The question and Vibe's answer land in the person's own "Voice on <machine>" chat (one
++ * per machine), the answer comes back as plain text to speak, it counts against their allowance, the same key answers
++ * once, and a person Chat has not seen or the allowance spent is refused with why. Only a service token
++ * holding the capability, from loopback, reaches it.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe ask');
++let h, ann, config, vibe;
++const BOT = (cap = 'chat.vibe.ask') => h.serviceToken([cap], { sub: 'svc:bot' });
++const DEVICE = 'dev_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const ask = (body, token = BOT(), headers = {}) => h.http('POST', '/internal/chat/vibe/ask', { token, body, headers });
++const calls = [];
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ config = require('../server/config');
++ vibe = require('../server/rooms/vibe');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++ require('../server/ai/client')._setClient({
++ runs: {
++ create: async (workflow, input) => {
++ calls.push({ workflow, input });
++ return { run: { status: 'succeeded', output: { choices: [{ message: { content: '**Clear skies** after six. See [the forecast](https://example.org/w) for more:\n\n- around 12 °C\n- light wind' } }] } } };
++ },
++ },
++ });
++});
++
++t('a question from a machine: kept in "Voice on <machine>", answered as plain text, counted', async () => {
++ const subject = h.live.users.get(ann.id).subject_id;
++ const before = await vibe.repliesToday({ id: ann.id });
++ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, lang: 'en-US', idempotency_key: 'va-001' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.state, 'answered');
++ assert.strictEqual(r.body.text, 'Clear skies after six. See the forecast for more:\naround 12 °C\nlight wind', 'Markdown and links off, for speech');
++ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);
++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);
++ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id)], ['Voice on desk-pc', `voice:${DEVICE}`, ann.id]);
++ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);
++ assert.deepStrictEqual(msgs.map((m) => (m.ai_member_id ? 'vibe' : m.message)), ['what is the weather like this evening', 'vibe'], 'the question and the answer, in their chat');
++ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before + 1, 'it counts against their allowance');
++ // A second question from the same machine goes to the same chat; another machine gets its own.
++ const again = await ask({ subject, text: 'and tomorrow?', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-002' });
++ assert.strictEqual(again.body.chat, r.body.chat);
++ const other = await ask({ subject, text: 'hello', source: { kind: 'voice', id: 'dev_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'robot' } });
++ assert.notStrictEqual(other.body.chat, r.body.chat);
++});
++
++t('the same key answers once: asked again, the first answer and no second model call', async () => {
++ const subject = h.live.users.get(ann.id).subject_id;
++ const before = calls.length;
++ const r = await ask({ subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: DEVICE, name: 'desk-pc' }, idempotency_key: 'va-001' });
++ assert.strictEqual(r.body.state, 'answered');
++ assert.match(r.body.url, /\/vibe\/v-/);
++ assert.strictEqual(calls.length, before, 'no model call');
++});
++
++t('refused, with why: a person Chat has not seen, the allowance spent', async () => {
++ const subject = h.live.users.get(ann.id).subject_id;
++ const unknown = await ask({ subject: ids.newId('user'), text: 'hi', source: { kind: 'voice', id: DEVICE } });
++ assert.deepStrictEqual([unknown.status, unknown.body.state, unknown.body.code], [200, 'refused', 'chat.vibe.unknown_person']);
++ const keep = config.ai.vibeRepliesPerDay;
++ config.ai.vibeRepliesPerDay = await vibe.repliesToday({ id: ann.id });
++ try {
++ const spent = await ask({ subject, text: 'one more', source: { kind: 'voice', id: DEVICE } });
++ assert.deepStrictEqual([spent.body.state, spent.body.code], ['refused', 'chat.vibe.allowance']);
++ assert.match(spent.body.text, /free answers/);
++ } finally { config.ai.vibeRepliesPerDay = keep; }
++});
++
++t('only a service token with chat.vibe.ask, from loopback; bad bodies are refused', async () => {
++ const subject = h.live.users.get(ann.id).subject_id;
++ const body = { subject, text: 'hi', source: { kind: 'voice', id: DEVICE } };
++ assert.strictEqual((await ask(body, null)).status, 401);
++ assert.strictEqual((await ask(body, BOT('chat.ticket.write'))).status, 403, 'another capability');
++ assert.strictEqual((await ask(body, BOT(), { 'x-forwarded-for': '203.0.113.9' })).status, 403, 'not through the proxy');
++ for (const bad of [{ ...body, subject: 'ann' }, { ...body, text: '' }, { ...body, source: { kind: 'chat', id: DEVICE } }, { ...body, extra: 1 }]) {
++ assert.strictEqual((await ask(bad)).status, 400, JSON.stringify(bad));
++ }
++});
++
++t('account export and deletion include what they asked by voice', async () => {
++ const ad = require('../server/chat/account-data');
++ const src = require('fs').readFileSync(require.resolve('../server/chat/account-data'), 'utf8');
++ assert.ok(src.includes("['vibe_asks.json'") && src.includes("['vibe_asks', 'DELETE FROM vibe_asks"), 'listed in both');
++ assert.ok(ad, 'module loads');
++});
++
++t.run(async () => { require('../server/ai/client')._setClient(null); if (h && h.close) await h.close(); });
ddiff --git a/test/vibe-deliver.test.js b/test/vibe-deliver.test.js
nnew file mode 100644
iindex 0000000..d8b7767
---- /dev/null
++++ b/test/vibe-deliver.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * Each run is a chat you can open (server/chat/internal-vibe.js POST /internal/chat/vibe/deliver, capability
++ * chat.vibe.deliver, contracts 0.214.0; migrations/0034_vibe_deliveries.sql): Actor posts an agent's run into the
++ * person's Vibe chat named after the agent, as Vibe's message with a link to the whole run; the next run of the same
++ * agent goes to the same chat, another agent gets its own; the same key posts once; no AI answer and nothing counted; the
++ * person replies and Vibe answers seeing the result; a person Chat has not seen is 422; only a service token with the
++ * capability reaches it; export and deletion carry the rows.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe deliver');
++let h, ann, vibe;
++const ACTOR = (cap = 'chat.vibe.deliver') => h.serviceToken([cap], { sub: 'svc:actor' });
++const AGENT = 'agt_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const send = (body, token = ACTOR()) => h.http('POST', '/internal/chat/vibe/deliver', { token, body });
++const calls = [];
++const pushes = [];
++let subject;
++const run = (n, extra = {}) => ({ subject, source: { kind: 'agent', id: AGENT, name: 'Remote jobs' }, text: `**${n} new jobs** today:\n\n- [Editor](https://example.com/jobs/${n})`, url: `https://openvibe.actor/tasks/tsk_run${n}`, idempotency_key: `tsk_run${n}`, ...extra });
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ vibe = require('../server/rooms/vibe');
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++ subject = h.live.users.get(ann.id).subject_id;
++ require('../server/inbox/notify')._setPush(async (n) => { pushes.push(n); return { sent: true }; });
++ require('../server/ai/client')._setClient({
++ runs: { create: async (workflow, input) => { calls.push({ workflow, input }); return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'The Editor one pays best.' } }] } } }; } },
++ });
++});
++
++t('a run lands in the chat named after its agent, as Vibe, with the link; no AI answer and nothing counted', async () => {
++ const before = await vibe.repliesToday({ id: ann.id });
++ const r = await send(run(3));
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.repeated, false);
++ assert.match(r.body.url, /^https:\/\/openvibe\.chat\/vibe\/v-[0-9a-f]{12}$/);
++ const room = await h.db.get('SELECT * FROM rooms WHERE slug = ?', [r.body.chat]);
++ assert.deepStrictEqual([room.name, room.assistant_source, Number(room.owner_id), Number(room.assistant)], ['Remote jobs', `agent:${AGENT}`, ann.id, 1]);
++ const msgs = await h.db.all('SELECT message, ai_member_id FROM room_messages WHERE room_id = ? ORDER BY id', [room.id]);
++ assert.strictEqual(msgs.length, 1);
++ assert.ok(msgs[0].ai_member_id, 'Vibe\'s message');
++ assert.strictEqual(msgs[0].message, '**3 new jobs** today:\n\n- [Editor](https://example.com/jobs/3)\n\n[See the whole run on OpenVibe Agents](https://openvibe.actor/tasks/tsk_run3)');
++ assert.strictEqual(calls.length, 0, 'no AI answer');
++ assert.strictEqual(await vibe.repliesToday({ id: ann.id }), before, 'nothing counted');
++ assert.ok((await vibe.list({ id: ann.id })).some((c) => c.slug === r.body.chat), 'listed with their chats');
++ for (let i = 0; i < 40 && !pushes.length; i++) await new Promise((res) => setTimeout(res, 25));
++ assert.deepStrictEqual(pushes.map((n) => [n.subjectId, n.type, n.category, n.title, n.message, n.url]),
++ [[subject, 'AGENT_RESULT', 'chat', 'Remote jobs has a new result', '3 new jobs today:', r.body.url]], 'one notification, to the chat');
++});
++
++t('the next run goes to the same chat; the same key posts once; another agent gets its own chat', async () => {
++ const first = await send(run(3));
++ assert.deepStrictEqual([first.status, first.body.repeated], [200, true]);
++ await new Promise((res) => setTimeout(res, 50));
++ assert.strictEqual(pushes.length, 1, 'a repeat tells nobody again');
++ const next = await send(run(4));
++ assert.strictEqual(next.status, 201);
++ const room = await h.db.get('SELECT id FROM rooms WHERE slug = ?', [next.body.chat]);
++ assert.strictEqual(Number((await h.db.get('SELECT count(*) AS n FROM room_messages WHERE room_id = ?', [room.id])).n), 2, 'one per run');
++ const other = await send(run(5, { source: { kind: 'agent', id: 'agt_01JABCDEFGHJKMNPQRSTVWXYZ1', name: 'Price watch' } }));
++ assert.notStrictEqual(other.body.chat, next.body.chat);
++ assert.strictEqual((await h.db.get('SELECT name FROM rooms WHERE slug = ?', [other.body.chat])).name, 'Price watch');
++});
++
++t('the person replies in the chat and Vibe answers seeing the result', async () => {
++ const room = await h.db.get('SELECT * FROM rooms WHERE assistant_source = ?', [`agent:${AGENT}`]);
++ const r = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: ann.token, body: { message: 'Which one pays best?' } });
++ assert.ok(r.status < 300, r.text);
++ for (let i = 0; i < 80 && !calls.length; i++) await new Promise((res) => setTimeout(res, 25));
++ assert.ok(calls.length, 'Vibe answered');
++ const prompt = JSON.stringify(calls[0].input);
++ assert.ok(prompt.includes('4 new jobs') && prompt.includes('Which one pays best?'), 'the result is in what Vibe reads');
++});
++
++t('refused: bad bodies, a person Chat has not seen, a token without the capability', async () => {
++ assert.strictEqual((await send(run(6, { source: { kind: 'voice', id: 'dev_1' } }))).status, 400);
++ assert.strictEqual((await send(run(6, { url: 'http://example.com/x' }))).status, 400);
++ assert.strictEqual((await send({ ...run(6), idempotency_key: undefined })).status, 400);
++ assert.strictEqual((await send(run(6, { text: 'x'.repeat(8001) }))).status, 400);
++ assert.strictEqual((await send(run(6, { subject: ids.newId('user') }))).status, 422);
++ assert.strictEqual((await send(run(6), ACTOR('chat.vibe.ask'))).status, 403);
++ assert.strictEqual((await h.http('POST', '/internal/chat/vibe/deliver', { body: run(6) })).status, 401);
++});
++
++t('account export and deletion include the posted runs', async () => {
++ const accountData = require('../server/chat/account-data');
++ const sent = [];
++ const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: subject },
++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: subject }, payload });
++ assert.strictEqual(await accountData.apply(ev('network.account.export_requested', { export_id: `exp_${ids.ulid()}`, subject, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() }), { send: async (p, body) => { sent.push(body); return { ok: true, status: 200 }; } }), 'exported');
++ const files = Object.fromEntries(sent[0].files.map((f) => [f.name, f.content]));
++ assert.strictEqual(files['vibe_deliveries.json'].length, 3);
++ assert.strictEqual(await accountData.apply(ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() }), { send: async () => ({ ok: true, status: 200 }) }), 'erased');
++ assert.strictEqual(await h.db.get('SELECT 1 FROM vibe_deliveries WHERE user_id = ?', [ann.id]), undefined);
++});
++
++t.run(async () => { require('../server/ai/client')._setClient(null); require('../server/inbox/notify')._setPush(null); if (h && h.close) await h.close(); });