openvibers/openvibe.sdk Commits
openvibe-sdk/chat: Vibe, support inboxes, feeds; createInboxVisitor and createFeedPublisher (#78)
Changes
@@@ -177,7 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) || `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` || `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |--| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |++| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) || `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` || `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade || `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |@@@ -2148,6 +2148,17 @@ function (module, exports) {* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is* null from get().++ *++ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's++ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the++ * owner's side). Two clients need no account at all and answer from any site, without cookies:++ *++ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel++ * const { token } = await visitor.visit(); // keep it: it is the visitor++ * await visitor.send('Where is my order?', { page_url: location.href });++ *++ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key++ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes*/const { isOpenVibeError } = __load(3);@@@ -2160,6 +2171,8 @@ function createChatClient(client, defaults = {}) {const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });const room = (slug) => `/api/chat/rooms/${enc(slug)}`;const conv = (id) => `/api/dm/conversations/${enc(id)}`;++ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;++ const feed = (id) => `/api/chat/feeds/${enc(id)}`;return {global: {@@@ -2203,10 +2216,161 @@ function createChatClient(client, defaults = {}) {},/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),++ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */++ vibe: {++ /** { chats, allowance } */++ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),++ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */++ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),++ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),++ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),++ },++ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */++ inboxes: {++ /** { inboxes }: those you work in, with waiting and open counts */++ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),++ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),++ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),++ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),++ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),++ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),++ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */++ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),++ /** query: after (a message id) → { conversation, messages } */++ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),++ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */++ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),++ /** status: open | resolved | bot (back to Vibe) */++ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),++ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */++ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),++ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */++ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),++ /** A reply draft in the visitor's language → { text, lang } */++ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),++ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),++ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */++ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),++ /** Saved replies: { replies } */++ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),++ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),++ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),++ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */++ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),++ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),++ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),++ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),++ /**++ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).++ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }++ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).++ */++ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),++ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),++ /** Call it now with these arguments → { ok, status, error, result } */++ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),++ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),++ },++ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */++ support: {++ /** { conversations }: newest first, each with its inbox's name */++ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),++ /** query: after → { conversation, messages } */++ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),++ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */++ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),++ },++ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */++ feeds: {++ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),++ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),++ /** A new key; the old one stops working. → { key } */++ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),++ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),++ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */++ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),++ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */++ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),++ },++ };++}++++/**++ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and++ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the++ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.++ *++ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's++ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same++ * token on every device. A wrong hash is just an anonymous visitor.++ *++ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes++ * theirs, in their openvibe.chat Messages and on every device.++ */++function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {++ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');++ let current = token || null;++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });++ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;++ const once = { idempotencyKey: false };++ return {++ /** The visitor's token (null before the first visit). */++ get token() { return current; },++ /** { inbox }: name, greeting, color, whether Vibe answers */++ config: (o = {}) => call({ path: path() }, o),++ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */++ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },++ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),++ /** query: after (a message id) → { conversation, messages } */++ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),++ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */++ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),++ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */++ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),++ /**++ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,++ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403++ * inbox.sign_in_to_attach.++ */++ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({++ method: 'POST', path: path('/picture'), body: picture, ...once,++ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },++ }, o),++ };++}++++/**++ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's++ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |++ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short++ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events++ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.++ */++function createFeedPublisher(client, { feed, key, baseUrl } = {}) {++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');++ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });++ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;++ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));++ return {++ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */++ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),++ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */++ publish: (events, o = {}) => {++ const list = Array.isArray(events) ? events : [events];++ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);++ },++ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */++ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),};}--module.exports = { createChatClient };++module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };},// ── 19: src/ai.js ───────────────────────────────────────────────────function (module, exports) {@@@ -56,7 +56,9 @@},"chat": {"object": [-- "createChatClient"++ "createChatClient",++ "createFeedPublisher",++ "createInboxVisitor"]},"commerce": {@@@ -382,7 +384,9 @@"createCache": "function"},"openvibe-sdk/chat": {-- "createChatClient": "function"++ "createChatClient": "function",++ "createFeedPublisher": "function",++ "createInboxVisitor": "function"},"openvibe-sdk/commerce": {"CommerceError": "class",@@@ -2,6 +2,6 @@import cjs from '../src/chat.js';export const {-- createChatClient,++ createChatClient, createInboxVisitor, createFeedPublisher,} = cjs;export default cjs;@@@ -14,6 +14,17 @@* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is* null from get().++ *++ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's++ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the++ * owner's side). Two clients need no account at all and answer from any site, without cookies:++ *++ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel++ * const { token } = await visitor.visit(); // keep it: it is the visitor++ * await visitor.send('Where is my order?', { page_url: location.href });++ *++ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key++ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes*/const { isOpenVibeError } = require('./core/errors');@@@ -26,6 +37,8 @@ function createChatClient(client, defaults = {}) {const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });const room = (slug) => `/api/chat/rooms/${enc(slug)}`;const conv = (id) => `/api/dm/conversations/${enc(id)}`;++ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;++ const feed = (id) => `/api/chat/feeds/${enc(id)}`;return {global: {@@@ -69,7 +82,158 @@ function createChatClient(client, defaults = {}) {},/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),++ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */++ vibe: {++ /** { chats, allowance } */++ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),++ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */++ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),++ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),++ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),++ },++ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */++ inboxes: {++ /** { inboxes }: those you work in, with waiting and open counts */++ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),++ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),++ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),++ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),++ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),++ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),++ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */++ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),++ /** query: after (a message id) → { conversation, messages } */++ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),++ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */++ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),++ /** status: open | resolved | bot (back to Vibe) */++ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),++ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */++ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),++ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */++ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),++ /** A reply draft in the visitor's language → { text, lang } */++ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),++ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),++ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */++ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),++ /** Saved replies: { replies } */++ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),++ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),++ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),++ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */++ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),++ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),++ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),++ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),++ /**++ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).++ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }++ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).++ */++ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),++ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),++ /** Call it now with these arguments → { ok, status, error, result } */++ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),++ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),++ },++ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */++ support: {++ /** { conversations }: newest first, each with its inbox's name */++ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),++ /** query: after → { conversation, messages } */++ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),++ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */++ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),++ },++ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */++ feeds: {++ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),++ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),++ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),++ /** A new key; the old one stops working. → { key } */++ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),++ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),++ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */++ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),++ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */++ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),++ },++ };++}++++/**++ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and++ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the++ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.++ *++ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's++ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same++ * token on every device. A wrong hash is just an anonymous visitor.++ *++ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes++ * theirs, in their openvibe.chat Messages and on every device.++ */++function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {++ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');++ let current = token || null;++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });++ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;++ const once = { idempotencyKey: false };++ return {++ /** The visitor's token (null before the first visit). */++ get token() { return current; },++ /** { inbox }: name, greeting, color, whether Vibe answers */++ config: (o = {}) => call({ path: path() }, o),++ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */++ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },++ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),++ /** query: after (a message id) → { conversation, messages } */++ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),++ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */++ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),++ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */++ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),++ /**++ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,++ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403++ * inbox.sign_in_to_attach.++ */++ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({++ method: 'POST', path: path('/picture'), body: picture, ...once,++ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },++ }, o),++ };++}++++/**++ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's++ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |++ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short++ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events++ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.++ */++function createFeedPublisher(client, { feed, key, baseUrl } = {}) {++ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');++ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });++ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;++ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));++ return {++ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */++ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),++ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */++ publish: (events, o = {}) => {++ const list = Array.isArray(events) ? events : [events];++ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);++ },++ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */++ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),};}--module.exports = { createChatClient };++module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };@@@ -1,9 +1,13 @@'use strict';--/** Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. */++/**++ * Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. Inboxes,++ * feeds and Vibe go to their paths; the inbox visitor and the feed publisher send no account token and no cookies, the++ * visitor keeps its token, the publisher sends the feed's key and retries only batches whose events all have an id.++ */const assert = require('node:assert/strict');const { stubServer, send, run } = require('./helpers');const { createClient } = require('../src/core');--const { createChatClient } = require('../src/chat');++const { createChatClient, createInboxVisitor, createFeedPublisher } = require('../src/chat');async function chatStub() {let busy = 0;@@@ -48,4 +52,83 @@ run([assert.equal(await chat.rooms.get('private-one'), null);await srv.close();}],++ ['inboxes, feeds and Vibe go to their paths', async () => {++ const srv = await chatStub();++ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 'user-jwt' }));++ await chat.inboxes.create({ name: 'Acme' });++ await chat.inboxes.conversations('inb_X', { status: 'waiting' });++ await chat.inboxes.reply('inb_X', 'cnv_Y', 'On its way');++ await chat.inboxes.setStatus('inb_X', 'cnv_Y', 'resolved');++ await chat.feeds.create({ title: 'Building', slot: 77 });++ await chat.feeds.rotateKey('fed_Z');++ await chat.feeds.view('fed_Z', { limit: 5 });++ await chat.feeds.onLive({ slot: [77, 78] });++ await chat.vibe.start('hello');++ await chat.inboxes.saveReply('inb_X', 'refund', 'Five days.');++ await chat.inboxes.addWebhook('inb_X', 'https://hooks.slack.com/services/x');++ await chat.inboxes.assign('inb_X', 'cnv_Y', 7);++ await chat.inboxes.addAction('inb_X', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' });++ await chat.inboxes.testAction('inb_X', 3, { order_id: '42' });++ const got = srv.requests.map((r) => [r.method, r.url, r.body && String(r.body).length ? JSON.parse(String(r.body)) : null]);++ assert.deepEqual(got, [++ ['POST', '/api/chat/inboxes', { name: 'Acme' }],++ ['GET', '/api/chat/inboxes/inb_X/conversations?status=waiting', null],++ ['POST', '/api/chat/inboxes/inb_X/conversations/cnv_Y/messages', { message: 'On its way' }],++ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { status: 'resolved' }],++ ['POST', '/api/chat/feeds', { title: 'Building', slot: 77 }],++ ['POST', '/api/chat/feeds/fed_Z/key', null],++ ['GET', '/api/chat/feeds/fed_Z?limit=5', null],++ ['GET', '/api/chat/feeds?slot=77%2C78', null],++ ['POST', '/api/chat/vibe', { message: 'hello' }],++ ['POST', '/api/chat/inboxes/inb_X/replies', { shortcut: 'refund', body: 'Five days.' }],++ ['POST', '/api/chat/inboxes/inb_X/webhooks', { url: 'https://hooks.slack.com/services/x' }],++ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { assignee_id: 7 }],++ ['POST', '/api/chat/inboxes/inb_X/actions', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' }],++ ['POST', '/api/chat/inboxes/inb_X/actions/3/test', { arguments: { order_id: '42' } }],++ ]);++ assert.equal(srv.requests[6].headers.authorization, undefined, 'a feed is read without the person\'s token');++ await srv.close();++ }],++ ['an inbox visitor needs no account and keeps its token; a feed publisher sends the key', async () => {++ const INBOX = `inb_${'A'.repeat(26)}`; const FEED = `fed_${'B'.repeat(26)}`; const KEY = `ovf_${'k'.repeat(32)}`;++ let fails = 1;++ const srv = await stubServer((req, res) => {++ if (req.url.endsWith('/visit')) return send(res, 200, { token: 'T'.repeat(32), conversation: null, messages: [] });++ if (req.url.endsWith('/events') && fails-- > 0) return send(res, 503, { error: 'busy' });++ return send(res, 200, { ok: true });++ });++ const client = createClient({ baseUrls: { chat: srv.url }, token: 'someone-else', retries: 2 });++ const v = createInboxVisitor(client, { inbox: INBOX });++ await v.visit();++ assert.equal(v.token, 'T'.repeat(32));++ await v.send('hi', { page_url: 'https://shop.example/' });++ await v.messages({ after: 3 });++ await v.rate(1, { comment: 'Quick!' });++ assert.deepEqual(JSON.parse(String(srv.requests[3].body)), { token: 'T'.repeat(32), rating: 1, comment: 'Quick!' });++ const sent = srv.requests.slice(0, 3);++ assert.ok(sent.every((r) => r.headers.authorization === undefined), 'no account token');++ assert.deepEqual(JSON.parse(String(sent[1].body)), { token: 'T'.repeat(32), message: 'hi', page_url: 'https://shop.example/' });++ assert.equal(sent[2].headers['x-visitor-token'], 'T'.repeat(32));++ const signed = createInboxVisitor(client, { inbox: INBOX, user: { id: 'cust-42', hash: 'a'.repeat(64) } });++ await signed.visit();++ assert.deepEqual(JSON.parse(String(srv.requests[srv.requests.length - 1].body)).user, { id: 'cust-42', hash: 'a'.repeat(64) }, 'a verified visitor sends who they are');++ const mine = createInboxVisitor(client, { inbox: INBOX, account: 'net-jwt' });++ await mine.visit();++ assert.equal(srv.requests[srv.requests.length - 1].headers.authorization, 'Bearer net-jwt', 'a signed-in visitor sends their own sign-in');++ await mine.sendPicture(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }), { caption: 'The crack, here' });++ const pic = srv.requests[srv.requests.length - 1];++ assert.deepEqual([pic.url, pic.headers['content-type'], pic.headers['x-visitor-token'], decodeURIComponent(pic.headers['x-caption']), pic.headers.authorization, pic.body.length],++ [`/api/chat/widget/inbox/${INBOX}/picture`, 'image/png', 'T'.repeat(32), 'The crack, here', 'Bearer net-jwt', 4], 'a picture goes as itself, with the token in a header');++ const f = createFeedPublisher(client, { feed: FEED, key: KEY });++ await f.publish([{ eventId: 'e1', eventType: 'prompt', text: 'x' }]);++ const pubs = srv.requests.filter((r) => r.url.endsWith('/events'));++ assert.equal(pubs.length, 2, 'a batch whose events all have ids is retried');++ assert.equal(pubs[0].headers.authorization, `Bearer ${KEY}`);++ fails = 1;++ await assert.rejects(f.publish({ eventType: 'prompt', text: 'no id' }), (e) => e.status === 503);++ assert.equal(srv.requests.filter((r) => r.url.endsWith('/events')).length, 3, 'one without ids is not');++ assert.throws(() => createFeedPublisher(client, { feed: FEED, key: 'hbt_x' }), TypeError);++ assert.throws(() => createInboxVisitor(client, { inbox: 'nope' }), TypeError);++ await srv.close();++ }],]);@@@ -36,5 +36,99 @@ export interface ChatClient {unblock(userId: number | string, opts?: Opts): Promise<Json>;};exportMine(opts?: Opts): Promise<{ username: string; exported_at: string; total: number; truncated: boolean; messages: Json[] }>;++ vibe: {++ list(opts?: Opts): Promise<{ chats: Json[]; allowance?: Json }>;++ start(message: string, opts?: Opts): Promise<{ room: Json; message: Json }>;++ rename(slug: string, name: string, opts?: Opts): Promise<Json>;++ remove(slug: string, opts?: Opts): Promise<Json>;++ };++ inboxes: {++ list(opts?: Opts): Promise<{ inboxes: Json[] }>;++ create(input: InboxInput & { name: string }, opts?: Opts): Promise<{ inbox: Json; embed: { script: string; panel: string } }>;++ get(id: string, opts?: Opts): Promise<{ inbox: Json; role: 'owner' | 'agent'; members: Json[]; embed: { script: string; panel: string } } | null>;++ update(id: string, patch: InboxInput, opts?: Opts): Promise<Json>;++ remove(id: string, opts?: Opts): Promise<Json>;++ addMember(id: string, username: string, opts?: Opts): Promise<Json>;++ removeMember(id: string, userId: number | string, opts?: Opts): Promise<Json>;++ conversations(id: string, query?: { status?: 'active' | ConversationStatus }, opts?: Opts): Promise<{ conversations: Json[] }>;++ messages(id: string, convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] }>;++ reply(id: string, convId: string, message: string, opts?: Opts): Promise<{ message: InboxMessage }>;++ setStatus(id: string, convId: string, status: 'open' | 'resolved' | 'bot', opts?: Opts): Promise<{ conversation: Json }>;++ assign(id: string, convId: string, assigneeId: number | null, opts?: Opts): Promise<{ conversation: Json }>;++ translateMessage(id: string, convId: string, messageId: number | string, to: string, opts?: Opts): Promise<{ text: string; lang: string; cached: boolean }>;++ translateDraft(id: string, text: string, to: string, opts?: Opts): Promise<{ text: string; lang: string }>;++ read(id: string, convId: string, opts?: Opts): Promise<Json>;++ identity(id: string, action: 'on' | 'rotate' | 'off', opts?: Opts): Promise<{ identity_secret: string | null }>;++ replies(id: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;++ saveReply(id: string, shortcut: string, body: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;++ deleteReply(id: string, replyId: number | string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;++ webhooks(id: string, opts?: Opts): Promise<{ webhooks: Json[] }>;++ addWebhook(id: string, url: string, opts?: Opts): Promise<{ webhook: Json; secret: string | null }>;++ testWebhook(id: string, hookId: number | string, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null }>;++ removeWebhook(id: string, hookId: number | string, opts?: Opts): Promise<void | Json>;++ actions(id: string, opts?: Opts): Promise<{ actions: InboxAction[] }>;++ addAction(id: string, input: InboxActionInput, opts?: Opts): Promise<{ action: InboxAction; secret: string }>;++ testAction(id: string, actionId: number | string, args?: Record<string, string>, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null; result: string | null }>;++ removeAction(id: string, actionId: number | string, opts?: Opts): Promise<void | Json>;++ };++ support: {++ list(query?: { limit?: number }, opts?: Opts): Promise<{ conversations: Json[] }>;++ messages(convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] } | null>;++ send(convId: string, message: string, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;++ };++ feeds: {++ mine(opts?: Opts): Promise<{ feeds: Json[] }>;++ create(input?: FeedInput, opts?: Opts): Promise<{ feed: Json; key: string }>;++ update(id: string, patch: FeedInput, opts?: Opts): Promise<{ feed: Json }>;++ rotateKey(id: string, opts?: Opts): Promise<{ key: string }>;++ remove(id: string, opts?: Opts): Promise<void | Json>;++ view(id: string, query?: { limit?: number }, opts?: Opts): Promise<{ feed: Json; publisher: Json | null; events: FeedEvent[] } | null>;++ onLive(where: { slot: number | number[] } | { channel: string }, opts?: Opts): Promise<{ feeds: Json[] }>;++ };++}++export type ConversationStatus = 'bot' | 'waiting' | 'open' | 'resolved';++export interface InboxInput {++ name?: string; greeting?: string; color?: string; ai_enabled?: boolean; ai_instructions?: string; knowledge_urls?: string[] | string; allowed_origins?: string[] | string;++ /** false: a site assistant, only Vibe answers */++ handoff?: boolean;++ /** When the inbox's people answer; null: always */++ hours?: { tz: string; days: number[]; start: string; end: string } | null;++ /** round_robin: a visitor waiting goes to the next of the people */++ route?: 'none' | 'round_robin';++}++export interface InboxActionInput { name: string; description: string; url: string; params?: { name: string; description?: string }[] | string }++export interface InboxAction { id: number; name: string; description: string; url: string; params: { name: string; description: string }[]; last_status: number | null; last_error: string | null; last_at: string | null }++export interface InboxMessage { id: number; author: 'visitor' | 'agent' | 'ai' | 'system'; author_name?: string | null; body: string; created_at: string; /** a visitor's message written signed in: its Pics links show as pictures */ account?: true }++export interface FeedInput { title?: string; room?: string | number | null; slot?: number | null; settings?: Record<string, number | string | boolean> }++export type FeedEventType = 'session.status' | 'prompt' | 'response' | 'thinking' | 'tool.call' | 'file.change' | 'file.save';++export interface FeedEvent {++ eventType: FeedEventType;++ eventId?: string;++ sessionKey?: string;++ sequence?: number;++ occurredAt?: string;++ summary?: string;++ text?: string;++ prompt?: { text: string }; response?: { text: string }; thinking?: { text: string };++ tool?: { name: string; phase?: string; argumentsPreview?: string | null; resultPreview?: string | null };++ file?: { name?: string; relativePath?: string | null; operation?: string; changeCount?: number; snippet?: string | null };++ publisher?: Json; metadata?: Json; source?: Json;++}++export interface InboxVisitor {++ readonly token: string | null;++ config(opts?: Opts): Promise<{ inbox: { id: string; name: string; greeting: string; color: string; ai: boolean; people: boolean } }>;++ visit(opts?: Opts): Promise<{ token: string; verified?: boolean; account?: { name: string } | null; inbox: Json; conversation: Json | null; messages: InboxMessage[] }>;++ send(message: string, extra?: { page_url?: string; name?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;++ messages(query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json | null; messages: InboxMessage[] }>;++ handoff(opts?: Opts): Promise<{ conversation: Json }>;++ rate(rating: 1 | -1, extra?: { comment?: string; conversation?: string }, opts?: Opts): Promise<{ conversation: Json }>;++ sendPicture(picture: Blob | ArrayBuffer | Uint8Array, extra?: { caption?: string; contentType?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage; picture: { slug: string; direct: string; page: string } }>;++}++export interface FeedPublisher {++ hello(input?: { sessionKey?: string; publisher?: Json }, opts?: Opts): Promise<{ sessionKey: string; publisher: Json }>;++ publish(events: FeedEvent | FeedEvent[], opts?: Opts): Promise<{ events: { eventId: string; delivered: boolean }[] }>;++ claudeCode(hook: Json, opts?: Opts): Promise<Json>;}export function createChatClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): ChatClient;++export function createInboxVisitor(client: OpenVibeClient, options: { inbox: string; token?: string | null; user?: { id: string; name?: string; email?: string; hash: string } | null; account?: string | null; baseUrl?: string }): InboxVisitor;++export function createFeedPublisher(client: OpenVibeClient, options: { feed: string; key: string; baseUrl?: string }): FeedPublisher;
@@ -177,7 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -177,7 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
-| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
+| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
@@ -2148,6 +2148,17 @@ function (module, exports) {
@@ -2148,6 +2148,17 @@ function (module, exports) {
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* null from get().
* null from get().
+ *
+ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's
+ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the
+ * owner's side). Two clients need no account at all and answer from any site, without cookies:
+ *
+ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel
+ * const { token } = await visitor.visit(); // keep it: it is the visitor
+ * await visitor.send('Where is my order?', { page_url: location.href });
+ *
+ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key
+ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes
*/
*/
const { isOpenVibeError } = __load(3);
const { isOpenVibeError } = __load(3);
@@ -2160,6 +2171,8 @@ function createChatClient(client, defaults = {}) {
@@ -2160,6 +2171,8 @@ function createChatClient(client, defaults = {}) {
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
+ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;
+ const feed = (id) => `/api/chat/feeds/${enc(id)}`;
return {
return {
global: {
global: {
@@ -2203,10 +2216,161 @@ function createChatClient(client, defaults = {}) {
@@ -2203,10 +2216,161 @@ function createChatClient(client, defaults = {}) {
},
},
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
+ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */
+ vibe: {
+ /** { chats, allowance } */
+ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),
+ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */
+ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),
+ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),
+ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),
+ },
+ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */
+ inboxes: {
+ /** { inboxes }: those you work in, with waiting and open counts */
+ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),
+ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */
+ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),
+ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),
+ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),
+ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),
+ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),
+ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),
+ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */
+ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),
+ /** query: after (a message id) → { conversation, messages } */
+ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),
+ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */
+ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),
+ /** status: open | resolved | bot (back to Vibe) */
+ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),
+ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */
+ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),
+ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */
+ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),
+ /** A reply draft in the visitor's language → { text, lang } */
+ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),
+ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),
+ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */
+ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),
+ /** Saved replies: { replies } */
+ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),
+ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),
+ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),
+ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */
+ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),
+ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),
+ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),
+ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),
+ /**
+ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).
+ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }
+ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).
+ */
+ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),
+ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),
+ /** Call it now with these arguments → { ok, status, error, result } */
+ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),
+ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),
+ },
+ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */
+ support: {
+ /** { conversations }: newest first, each with its inbox's name */
+ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),
+ /** query: after → { conversation, messages } */
+ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),
+ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */
+ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),
+ },
+ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */
+ feeds: {
+ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),
+ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */
+ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),
+ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),
+ /** A new key; the old one stops working. → { key } */
+ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),
+ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),
+ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */
+ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),
+ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */
+ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),
+ },
+ };
+}
+
+/**
+ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and
+ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the
+ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.
+ *
+ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's
+ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same
+ * token on every device. A wrong hash is just an anonymous visitor.
+ *
+ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes
+ * theirs, in their openvibe.chat Messages and on every device.
+ */
+function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {
+ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');
+ let current = token || null;
+ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });
+ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;
+ const once = { idempotencyKey: false };
+ return {
+ /** The visitor's token (null before the first visit). */
+ get token() { return current; },
+ /** { inbox }: name, greeting, color, whether Vibe answers */
+ config: (o = {}) => call({ path: path() }, o),
+ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */
+ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },
+ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */
+ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),
+ /** query: after (a message id) → { conversation, messages } */
+ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),
+ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */
+ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),
+ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */
+ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),
+ /**
+ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,
+ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403
+ * inbox.sign_in_to_attach.
+ */
+ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({
+ method: 'POST', path: path('/picture'), body: picture, ...once,
+ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },
+ }, o),
+ };
+}
+
+/**
+ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's
+ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |
+ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short
+ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events
+ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.
+ */
+function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
+ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');
+ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');
+ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });
+ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;
+ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));
+ return {
+ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */
+ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),
+ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */
+ publish: (events, o = {}) => {
+ const list = Array.isArray(events) ? events : [events];
+ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);
+ },
+ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */
+ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),
};
};
}
}
-module.exports = { createChatClient };
+module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
},
// ── 19: src/ai.js ───────────────────────────────────────────────────
// ── 19: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
@@ -56,7 +56,9 @@
@@ -56,7 +56,9 @@
},
},
"chat": {
"chat": {
"object": [
"object": [
- "createChatClient"
+ "createChatClient",
+ "createFeedPublisher",
+ "createInboxVisitor"
]
]
},
},
"commerce": {
"commerce": {
@@ -382,7 +384,9 @@
@@ -382,7 +384,9 @@
"createCache": "function"
"createCache": "function"
},
},
"openvibe-sdk/chat": {
"openvibe-sdk/chat": {
- "createChatClient": "function"
+ "createChatClient": "function",
+ "createFeedPublisher": "function",
+ "createInboxVisitor": "function"
},
},
"openvibe-sdk/commerce": {
"openvibe-sdk/commerce": {
"CommerceError": "class",
"CommerceError": "class",
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/chat.js';
import cjs from '../src/chat.js';
export const {
export const {
- createChatClient,
+ createChatClient, createInboxVisitor, createFeedPublisher,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -14,6 +14,17 @@
@@ -14,6 +14,17 @@
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
* null from get().
* null from get().
+ *
+ * The rest of Chat as a platform (Chat's docs/platform.md): `vibe` (a person's chats with Vibe, OpenVibe's
+ * assistant), `inboxes` (support inboxes: the team's side) and `feeds` (live activity such as vibe coding: the
+ * owner's side). Two clients need no account at all and answer from any site, without cookies:
+ *
+ * const visitor = createInboxVisitor(client, { inbox: 'inb_…', token: saved }); // build your own chat panel
+ * const { token } = await visitor.visit(); // keep it: it is the visitor
+ * await visitor.send('Where is my order?', { page_url: location.href });
+ *
+ * const feed = createFeedPublisher(client, { feed: 'fed_…', key: feedKey }); // the feed's ovf_ key
+ * await feed.publish({ eventType: 'prompt', text: 'Add a dark mode' }); // or a list, or Live's envelopes
*/
*/
const { isOpenVibeError } = require('./core/errors');
const { isOpenVibeError } = require('./core/errors');
@@ -26,6 +37,8 @@ function createChatClient(client, defaults = {}) {
@@ -26,6 +37,8 @@ function createChatClient(client, defaults = {}) {
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
const conv = (id) => `/api/dm/conversations/${enc(id)}`;
+ const inbox = (id) => `/api/chat/inboxes/${enc(id)}`;
+ const feed = (id) => `/api/chat/feeds/${enc(id)}`;
return {
return {
global: {
global: {
@@ -69,7 +82,158 @@ function createChatClient(client, defaults = {}) {
@@ -69,7 +82,158 @@ function createChatClient(client, defaults = {}) {
},
},
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
/** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
+ /** Vibe, OpenVibe's assistant: the person's chats with it (people only; an API token gets 403). */
+ vibe: {
+ /** { chats, allowance } */
+ list: (o = {}) => call({ path: '/api/chat/vibe' }, o),
+ /** A new chat; Vibe answers in its room (rooms.messages, or the room socket). → { room, message } */
+ start: (message, o = {}) => call({ method: 'POST', path: '/api/chat/vibe', json: { message }, ...once }, o),
+ rename: (slug, name, o = {}) => call({ method: 'PATCH', path: `/api/chat/vibe/${enc(slug)}`, json: { name }, ...once }, o),
+ remove: (slug, o = {}) => call({ method: 'DELETE', path: `/api/chat/vibe/${enc(slug)}`, ...once }, o),
+ },
+ /** Support inboxes, the team's side: Vibe answers visitors first, the inbox's people take over. */
+ inboxes: {
+ /** { inboxes }: those you work in, with waiting and open counts */
+ list: (o = {}) => call({ path: '/api/chat/inboxes' }, o),
+ /** → { inbox, embed: { script, panel } }; embed.script is the one tag a site adds */
+ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/inboxes', json: input, ...once }, o),
+ get: (id, o = {}) => orNull(call({ path: inbox(id) }, o)),
+ update: (id, patch, o = {}) => call({ method: 'PATCH', path: inbox(id), json: patch, ...once }, o),
+ remove: (id, o = {}) => call({ method: 'DELETE', path: inbox(id), ...once }, o),
+ addMember: (id, username, o = {}) => call({ method: 'POST', path: `${inbox(id)}/members`, json: { username }, ...once }, o),
+ removeMember: (id, userId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/members/${enc(userId)}`, ...once }, o),
+ /** query: status = active | bot | waiting | open | resolved → { conversations } (waiting first) */
+ conversations: (id, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations`, query }, o),
+ /** query: after (a message id) → { conversation, messages } */
+ messages: (id, convId, query = {}, o = {}) => call({ path: `${inbox(id)}/conversations/${enc(convId)}/messages`, query }, o),
+ /** A person's answer: the conversation becomes open and Vibe stays quiet in it. */
+ reply: (id, convId, message, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages`, json: { message }, ...once }, o),
+ /** status: open | resolved | bot (back to Vibe) */
+ setStatus: (id, convId, status, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { status }, ...once }, o),
+ /** Hand a conversation to one of the inbox's people (their user id), or null to unassign. */
+ assign: (id, convId, assigneeId, o = {}) => call({ method: 'PATCH', path: `${inbox(id)}/conversations/${enc(convId)}`, json: { assignee_id: assigneeId == null ? null : assigneeId }, ...once }, o),
+ /** A visitor's message in the reader's language (kept, so the next reader pays nothing) → { text, lang, cached } */
+ translateMessage: (id, convId, messageId, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/messages/${enc(messageId)}/translate`, json: { to }, ...once }, o),
+ /** A reply draft in the visitor's language → { text, lang } */
+ translateDraft: (id, text, to, o = {}) => call({ method: 'POST', path: `${inbox(id)}/translate`, json: { text, to }, ...once }, o),
+ read: (id, convId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/conversations/${enc(convId)}/read`, ...once }, o),
+ /** Verified visitors (owner): action on | rotate | off → { identity_secret } (keep it on your server) */
+ identity: (id, action, o = {}) => call({ method: 'POST', path: `${inbox(id)}/identity`, json: { action }, ...once }, o),
+ /** Saved replies: { replies } */
+ replies: (id, o = {}) => call({ path: `${inbox(id)}/replies` }, o),
+ saveReply: (id, shortcut, body, o = {}) => call({ method: 'POST', path: `${inbox(id)}/replies`, json: { shortcut, body }, ...once }, o),
+ deleteReply: (id, replyId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/replies/${enc(replyId)}`, ...once }, o),
+ /** Webhooks (owner): { webhooks }; add → { webhook, secret } (the secret once, for signed JSON) */
+ webhooks: (id, o = {}) => call({ path: `${inbox(id)}/webhooks` }, o),
+ addWebhook: (id, url, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks`, json: { url }, ...once }, o),
+ testWebhook: (id, hookId, o = {}) => call({ method: 'POST', path: `${inbox(id)}/webhooks/${enc(hookId)}/test`, ...once }, o),
+ removeWebhook: (id, hookId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/webhooks/${enc(hookId)}`, ...once }, o),
+ /**
+ * Actions (owner): the team's own https endpoints Vibe may ask while it answers (look up an order).
+ * input { name, description, url, params: [{ name, description }] | 'name: what it is' lines } → { action, secret }
+ * (the secret once: each call is signed like an Events delivery, so parseDelivery checks it).
+ */
+ actions: (id, o = {}) => call({ path: `${inbox(id)}/actions` }, o),
+ addAction: (id, input, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions`, json: input, ...once }, o),
+ /** Call it now with these arguments → { ok, status, error, result } */
+ testAction: (id, actionId, args = {}, o = {}) => call({ method: 'POST', path: `${inbox(id)}/actions/${enc(actionId)}/test`, json: { arguments: args }, ...once }, o),
+ removeAction: (id, actionId, o = {}) => call({ method: 'DELETE', path: `${inbox(id)}/actions/${enc(actionId)}`, ...once }, o),
+ },
+ /** The person's own conversations with sites' chats (they signed in to OpenVibe in the panel). */
+ support: {
+ /** { conversations }: newest first, each with its inbox's name */
+ list: (query = {}, o = {}) => call({ path: '/api/chat/support', query }, o),
+ /** query: after → { conversation, messages } */
+ messages: (convId, query = {}, o = {}) => orNull(call({ path: `/api/chat/support/${enc(convId)}/messages`, query }, o)),
+ /** → { conversation, message }; writing to a resolved one starts a new conversation with that inbox */
+ send: (convId, message, o = {}) => call({ method: 'POST', path: `/api/chat/support/${enc(convId)}/messages`, json: { message }, ...once }, o),
+ },
+ /** Feeds, the owner's side (publishing is createFeedPublisher, with the feed's key). */
+ feeds: {
+ mine: (o = {}) => call({ path: '/api/chat/feeds/mine' }, o),
+ /** { title?, room?, slot?, settings? } → { feed, key }: the key is shown only this once */
+ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/chat/feeds', json: input, ...once }, o),
+ update: (id, patch, o = {}) => call({ method: 'PATCH', path: feed(id), json: patch, ...once }, o),
+ /** A new key; the old one stops working. → { key } */
+ rotateKey: (id, o = {}) => call({ method: 'POST', path: `${feed(id)}/key`, ...once }, o),
+ remove: (id, o = {}) => call({ method: 'DELETE', path: feed(id), ...once }, o),
+ /** What a viewer sees now (after the feed's delay): { feed, publisher, events }. Anyone may read it. */
+ view: (id, query = {}, o = {}) => orNull(call({ path: feed(id), query, auth: false, credentials: 'omit' }, o)),
+ /** The feeds shown on Live channel slots: { slot: [ids] } or { channel: username } → { feeds } */
+ onLive: (where, o = {}) => call({ path: '/api/chat/feeds', query: where.channel ? { channel: where.channel } : { slot: [].concat(where.slot || []).join(',') }, auth: false, credentials: 'omit' }, o),
+ },
+ };
+}
+
+/**
+ * A support inbox's visitor, for a site that builds its own panel instead of openvibe.chat/widget.js. No account and
+ * no cookies: the visitor is the token visit() returns, which the site keeps (and passes back next time to resume the
+ * conversation). An inbox that names its allowed sites answers only them. Writes are not retried.
+ *
+ * user (an inbox with verified visitors): { id, name?, email?, hash } where hash is HMAC-SHA256 of id with the inbox's
+ * identity secret, in hex, made on your server; Chat then trusts the name and email and gives the person the same
+ * token on every device. A wrong hash is just an anonymous visitor.
+ *
+ * account: the visitor's own OpenVibe sign-in (a Network token), when they have one: their conversation becomes
+ * theirs, in their openvibe.chat Messages and on every device.
+ */
+function createInboxVisitor(client, { inbox, token = null, user = null, account = null, baseUrl } = {}) {
+ if (!/^inb_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(inbox || ''))) throw new TypeError('createInboxVisitor: inbox is an inb_ id');
+ let current = token || null;
+ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', ...opts, headers: { ...(account ? { Authorization: `Bearer ${account}` } : {}), ...(opts.headers || {}) }, signal: o.signal });
+ const path = (p = '') => `/api/chat/widget/inbox/${enc(inbox)}${p}`;
+ const once = { idempotencyKey: false };
+ return {
+ /** The visitor's token (null before the first visit). */
+ get token() { return current; },
+ /** { inbox }: name, greeting, color, whether Vibe answers */
+ config: (o = {}) => call({ path: path() }, o),
+ /** → { token, inbox, conversation, messages }; a missing or unknown token gets a new one */
+ visit: async (o = {}) => { const d = await call({ method: 'POST', path: path('/visit'), json: { token: current, ...(user ? { user } : {}) }, ...once }, o); current = d.token; return d; },
+ /** extra: page_url, name → { conversation, message }; after a resolved conversation this starts a new one */
+ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: path('/messages'), json: { token: current, message, ...(user ? { user } : {}), ...extra }, ...once }, o),
+ /** query: after (a message id) → { conversation, messages } */
+ messages: (query = {}, o = {}) => call({ path: path('/messages'), query, headers: { 'X-Visitor-Token': current } }, o),
+ /** "Talk to a person" → { conversation } (409 when only Vibe answers in this inbox: config().inbox.people false) */
+ handoff: (o = {}) => call({ method: 'POST', path: path('/handoff'), json: { token: current }, ...once }, o),
+ /** Rate a resolved conversation: 1 (good) or -1 (bad), with a line → { conversation } */
+ rate: (rating, extra = {}, o = {}) => call({ method: 'POST', path: path('/rating'), json: { token: current, rating, ...extra }, ...once }, o),
+ /**
+ * A picture (a Blob, File or Buffer: JPEG, PNG, GIF, WebP or AVIF), with an optional line → { conversation, message,
+ * picture }. Signed-in visitors only (`account`): it goes to OpenVibe.Pics as them; anyone else gets 403
+ * inbox.sign_in_to_attach.
+ */
+ sendPicture: (picture, { caption = '', contentType } = {}, o = {}) => call({
+ method: 'POST', path: path('/picture'), body: picture, ...once,
+ headers: { 'Content-Type': contentType || (picture && picture.type) || 'application/octet-stream', 'X-Visitor-Token': current, ...(caption ? { 'X-Caption': encodeURIComponent(String(caption)) } : {}) },
+ }, o),
+ };
+}
+
+/**
+ * Publish to a feed with its key (ovf_…, from feeds.create or the feed's page on openvibe.chat). Events are Live's
+ * canonical vibe coding events: eventType session.status | prompt | response | thinking | tool.call | file.change |
+ * file.save, with eventId, sessionKey, summary and the type's part ({ prompt: { text } }, { tool: { name } }, …); a short
+ * { eventType, text } works too. Chat scrubs secrets and home folders before anything is stored. A batch whose events
+ * all carry an eventId is retried (Chat stores an eventId once); otherwise it is not.
+ */
+function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
+ if (!/^fed_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(feed || ''))) throw new TypeError('createFeedPublisher: feed is a fed_ id');
+ if (!/^ovf_[A-Za-z0-9_-]{32}$/.test(String(key || ''))) throw new TypeError('createFeedPublisher: key is the feed\'s ovf_ key');
+ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, auth: false, credentials: 'omit', headers: { Authorization: `Bearer ${key}` }, ...opts, signal: o.signal });
+ const path = (p) => `/api/chat/feeds/${enc(feed)}${p}`;
+ const hasId = (e) => !!(e && (e.eventId || (e.event && e.event.eventId)));
+ return {
+ /** { sessionKey?, publisher?: { integrationId, integrationLabel, clientName, … } } → { sessionKey, publisher } */
+ hello: (input = {}, o = {}) => call({ method: 'POST', path: path('/hello'), json: input, idempotencyKey: false }, o),
+ /** One event or a list (at most 50) → { events: [{ eventId, delivered }] } */
+ publish: (events, o = {}) => {
+ const list = Array.isArray(events) ? events : [events];
+ return call({ method: 'POST', path: path('/events'), json: { events: list }, ...(list.every(hasId) ? { idempotent: true } : { idempotencyKey: false }) }, o);
+ },
+ /** Claude Code's hook JSON as it is (what an "http" hook posts) → {} */
+ claudeCode: (hook, o = {}) => call({ method: 'POST', path: path('/hooks/claude-code'), json: hook, idempotencyKey: false }, o),
};
};
}
}
-module.exports = { createChatClient };
+module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
'use strict';
'use strict';
-/** Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. */
+/**
+ * Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. Inboxes,
+ * feeds and Vibe go to their paths; the inbox visitor and the feed publisher send no account token and no cookies, the
+ * visitor keeps its token, the publisher sends the feed's key and retries only batches whose events all have an id.
+ */
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { stubServer, send, run } = require('./helpers');
const { stubServer, send, run } = require('./helpers');
const { createClient } = require('../src/core');
const { createClient } = require('../src/core');
-const { createChatClient } = require('../src/chat');
+const { createChatClient, createInboxVisitor, createFeedPublisher } = require('../src/chat');
async function chatStub() {
async function chatStub() {
let busy = 0;
let busy = 0;
@@ -48,4 +52,83 @@ run([
@@ -48,4 +52,83 @@ run([
assert.equal(await chat.rooms.get('private-one'), null);
assert.equal(await chat.rooms.get('private-one'), null);
await srv.close();
await srv.close();
}],
}],
+ ['inboxes, feeds and Vibe go to their paths', async () => {
+ const srv = await chatStub();
+ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 'user-jwt' }));
+ await chat.inboxes.create({ name: 'Acme' });
+ await chat.inboxes.conversations('inb_X', { status: 'waiting' });
+ await chat.inboxes.reply('inb_X', 'cnv_Y', 'On its way');
+ await chat.inboxes.setStatus('inb_X', 'cnv_Y', 'resolved');
+ await chat.feeds.create({ title: 'Building', slot: 77 });
+ await chat.feeds.rotateKey('fed_Z');
+ await chat.feeds.view('fed_Z', { limit: 5 });
+ await chat.feeds.onLive({ slot: [77, 78] });
+ await chat.vibe.start('hello');
+ await chat.inboxes.saveReply('inb_X', 'refund', 'Five days.');
+ await chat.inboxes.addWebhook('inb_X', 'https://hooks.slack.com/services/x');
+ await chat.inboxes.assign('inb_X', 'cnv_Y', 7);
+ await chat.inboxes.addAction('inb_X', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' });
+ await chat.inboxes.testAction('inb_X', 3, { order_id: '42' });
+ const got = srv.requests.map((r) => [r.method, r.url, r.body && String(r.body).length ? JSON.parse(String(r.body)) : null]);
+ assert.deepEqual(got, [
+ ['POST', '/api/chat/inboxes', { name: 'Acme' }],
+ ['GET', '/api/chat/inboxes/inb_X/conversations?status=waiting', null],
+ ['POST', '/api/chat/inboxes/inb_X/conversations/cnv_Y/messages', { message: 'On its way' }],
+ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { status: 'resolved' }],
+ ['POST', '/api/chat/feeds', { title: 'Building', slot: 77 }],
+ ['POST', '/api/chat/feeds/fed_Z/key', null],
+ ['GET', '/api/chat/feeds/fed_Z?limit=5', null],
+ ['GET', '/api/chat/feeds?slot=77%2C78', null],
+ ['POST', '/api/chat/vibe', { message: 'hello' }],
+ ['POST', '/api/chat/inboxes/inb_X/replies', { shortcut: 'refund', body: 'Five days.' }],
+ ['POST', '/api/chat/inboxes/inb_X/webhooks', { url: 'https://hooks.slack.com/services/x' }],
+ ['PATCH', '/api/chat/inboxes/inb_X/conversations/cnv_Y', { assignee_id: 7 }],
+ ['POST', '/api/chat/inboxes/inb_X/actions', { name: 'look_up_order', description: 'Finds an order.', url: 'https://shop.example/vibe', params: 'order_id: the number' }],
+ ['POST', '/api/chat/inboxes/inb_X/actions/3/test', { arguments: { order_id: '42' } }],
+ ]);
+ assert.equal(srv.requests[6].headers.authorization, undefined, 'a feed is read without the person\'s token');
+ await srv.close();
+ }],
+ ['an inbox visitor needs no account and keeps its token; a feed publisher sends the key', async () => {
+ const INBOX = `inb_${'A'.repeat(26)}`; const FEED = `fed_${'B'.repeat(26)}`; const KEY = `ovf_${'k'.repeat(32)}`;
+ let fails = 1;
+ const srv = await stubServer((req, res) => {
+ if (req.url.endsWith('/visit')) return send(res, 200, { token: 'T'.repeat(32), conversation: null, messages: [] });
+ if (req.url.endsWith('/events') && fails-- > 0) return send(res, 503, { error: 'busy' });
+ return send(res, 200, { ok: true });
+ });
+ const client = createClient({ baseUrls: { chat: srv.url }, token: 'someone-else', retries: 2 });
+ const v = createInboxVisitor(client, { inbox: INBOX });
+ await v.visit();
+ assert.equal(v.token, 'T'.repeat(32));
+ await v.send('hi', { page_url: 'https://shop.example/' });
+ await v.messages({ after: 3 });
+ await v.rate(1, { comment: 'Quick!' });
+ assert.deepEqual(JSON.parse(String(srv.requests[3].body)), { token: 'T'.repeat(32), rating: 1, comment: 'Quick!' });
+ const sent = srv.requests.slice(0, 3);
+ assert.ok(sent.every((r) => r.headers.authorization === undefined), 'no account token');
+ assert.deepEqual(JSON.parse(String(sent[1].body)), { token: 'T'.repeat(32), message: 'hi', page_url: 'https://shop.example/' });
+ assert.equal(sent[2].headers['x-visitor-token'], 'T'.repeat(32));
+ const signed = createInboxVisitor(client, { inbox: INBOX, user: { id: 'cust-42', hash: 'a'.repeat(64) } });
+ await signed.visit();
+ assert.deepEqual(JSON.parse(String(srv.requests[srv.requests.length - 1].body)).user, { id: 'cust-42', hash: 'a'.repeat(64) }, 'a verified visitor sends who they are');
+ const mine = createInboxVisitor(client, { inbox: INBOX, account: 'net-jwt' });
+ await mine.visit();
+ assert.equal(srv.requests[srv.requests.length - 1].headers.authorization, 'Bearer net-jwt', 'a signed-in visitor sends their own sign-in');
+ await mine.sendPicture(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }), { caption: 'The crack, here' });
+ const pic = srv.requests[srv.requests.length - 1];
+ assert.deepEqual([pic.url, pic.headers['content-type'], pic.headers['x-visitor-token'], decodeURIComponent(pic.headers['x-caption']), pic.headers.authorization, pic.body.length],
+ [`/api/chat/widget/inbox/${INBOX}/picture`, 'image/png', 'T'.repeat(32), 'The crack, here', 'Bearer net-jwt', 4], 'a picture goes as itself, with the token in a header');
+ const f = createFeedPublisher(client, { feed: FEED, key: KEY });
+ await f.publish([{ eventId: 'e1', eventType: 'prompt', text: 'x' }]);
+ const pubs = srv.requests.filter((r) => r.url.endsWith('/events'));
+ assert.equal(pubs.length, 2, 'a batch whose events all have ids is retried');
+ assert.equal(pubs[0].headers.authorization, `Bearer ${KEY}`);
+ fails = 1;
+ await assert.rejects(f.publish({ eventType: 'prompt', text: 'no id' }), (e) => e.status === 503);
+ assert.equal(srv.requests.filter((r) => r.url.endsWith('/events')).length, 3, 'one without ids is not');
+ assert.throws(() => createFeedPublisher(client, { feed: FEED, key: 'hbt_x' }), TypeError);
+ assert.throws(() => createInboxVisitor(client, { inbox: 'nope' }), TypeError);
+ await srv.close();
+ }],
]);
]);
@@ -36,5 +36,99 @@ export interface ChatClient {
@@ -36,5 +36,99 @@ export interface ChatClient {
unblock(userId: number | string, opts?: Opts): Promise<Json>;
unblock(userId: number | string, opts?: Opts): Promise<Json>;
};
};
exportMine(opts?: Opts): Promise<{ username: string; exported_at: string; total: number; truncated: boolean; messages: Json[] }>;
exportMine(opts?: Opts): Promise<{ username: string; exported_at: string; total: number; truncated: boolean; messages: Json[] }>;
+ vibe: {
+ list(opts?: Opts): Promise<{ chats: Json[]; allowance?: Json }>;
+ start(message: string, opts?: Opts): Promise<{ room: Json; message: Json }>;
+ rename(slug: string, name: string, opts?: Opts): Promise<Json>;
+ remove(slug: string, opts?: Opts): Promise<Json>;
+ };
+ inboxes: {
+ list(opts?: Opts): Promise<{ inboxes: Json[] }>;
+ create(input: InboxInput & { name: string }, opts?: Opts): Promise<{ inbox: Json; embed: { script: string; panel: string } }>;
+ get(id: string, opts?: Opts): Promise<{ inbox: Json; role: 'owner' | 'agent'; members: Json[]; embed: { script: string; panel: string } } | null>;
+ update(id: string, patch: InboxInput, opts?: Opts): Promise<Json>;
+ remove(id: string, opts?: Opts): Promise<Json>;
+ addMember(id: string, username: string, opts?: Opts): Promise<Json>;
+ removeMember(id: string, userId: number | string, opts?: Opts): Promise<Json>;
+ conversations(id: string, query?: { status?: 'active' | ConversationStatus }, opts?: Opts): Promise<{ conversations: Json[] }>;
+ messages(id: string, convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] }>;
+ reply(id: string, convId: string, message: string, opts?: Opts): Promise<{ message: InboxMessage }>;
+ setStatus(id: string, convId: string, status: 'open' | 'resolved' | 'bot', opts?: Opts): Promise<{ conversation: Json }>;
+ assign(id: string, convId: string, assigneeId: number | null, opts?: Opts): Promise<{ conversation: Json }>;
+ translateMessage(id: string, convId: string, messageId: number | string, to: string, opts?: Opts): Promise<{ text: string; lang: string; cached: boolean }>;
+ translateDraft(id: string, text: string, to: string, opts?: Opts): Promise<{ text: string; lang: string }>;
+ read(id: string, convId: string, opts?: Opts): Promise<Json>;
+ identity(id: string, action: 'on' | 'rotate' | 'off', opts?: Opts): Promise<{ identity_secret: string | null }>;
+ replies(id: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
+ saveReply(id: string, shortcut: string, body: string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
+ deleteReply(id: string, replyId: number | string, opts?: Opts): Promise<{ replies: { id: number; shortcut: string; body: string }[] }>;
+ webhooks(id: string, opts?: Opts): Promise<{ webhooks: Json[] }>;
+ addWebhook(id: string, url: string, opts?: Opts): Promise<{ webhook: Json; secret: string | null }>;
+ testWebhook(id: string, hookId: number | string, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null }>;
+ removeWebhook(id: string, hookId: number | string, opts?: Opts): Promise<void | Json>;
+ actions(id: string, opts?: Opts): Promise<{ actions: InboxAction[] }>;
+ addAction(id: string, input: InboxActionInput, opts?: Opts): Promise<{ action: InboxAction; secret: string }>;
+ testAction(id: string, actionId: number | string, args?: Record<string, string>, opts?: Opts): Promise<{ ok: boolean; status: number | null; error: string | null; result: string | null }>;
+ removeAction(id: string, actionId: number | string, opts?: Opts): Promise<void | Json>;
+ };
+ support: {
+ list(query?: { limit?: number }, opts?: Opts): Promise<{ conversations: Json[] }>;
+ messages(convId: string, query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json; messages: InboxMessage[] } | null>;
+ send(convId: string, message: string, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;
+ };
+ feeds: {
+ mine(opts?: Opts): Promise<{ feeds: Json[] }>;
+ create(input?: FeedInput, opts?: Opts): Promise<{ feed: Json; key: string }>;
+ update(id: string, patch: FeedInput, opts?: Opts): Promise<{ feed: Json }>;
+ rotateKey(id: string, opts?: Opts): Promise<{ key: string }>;
+ remove(id: string, opts?: Opts): Promise<void | Json>;
+ view(id: string, query?: { limit?: number }, opts?: Opts): Promise<{ feed: Json; publisher: Json | null; events: FeedEvent[] } | null>;
+ onLive(where: { slot: number | number[] } | { channel: string }, opts?: Opts): Promise<{ feeds: Json[] }>;
+ };
+}
+export type ConversationStatus = 'bot' | 'waiting' | 'open' | 'resolved';
+export interface InboxInput {
+ name?: string; greeting?: string; color?: string; ai_enabled?: boolean; ai_instructions?: string; knowledge_urls?: string[] | string; allowed_origins?: string[] | string;
+ /** false: a site assistant, only Vibe answers */
+ handoff?: boolean;
+ /** When the inbox's people answer; null: always */
+ hours?: { tz: string; days: number[]; start: string; end: string } | null;
+ /** round_robin: a visitor waiting goes to the next of the people */
+ route?: 'none' | 'round_robin';
+}
+export interface InboxActionInput { name: string; description: string; url: string; params?: { name: string; description?: string }[] | string }
+export interface InboxAction { id: number; name: string; description: string; url: string; params: { name: string; description: string }[]; last_status: number | null; last_error: string | null; last_at: string | null }
+export interface InboxMessage { id: number; author: 'visitor' | 'agent' | 'ai' | 'system'; author_name?: string | null; body: string; created_at: string; /** a visitor's message written signed in: its Pics links show as pictures */ account?: true }
+export interface FeedInput { title?: string; room?: string | number | null; slot?: number | null; settings?: Record<string, number | string | boolean> }
+export type FeedEventType = 'session.status' | 'prompt' | 'response' | 'thinking' | 'tool.call' | 'file.change' | 'file.save';
+export interface FeedEvent {
+ eventType: FeedEventType;
+ eventId?: string;
+ sessionKey?: string;
+ sequence?: number;
+ occurredAt?: string;
+ summary?: string;
+ text?: string;
+ prompt?: { text: string }; response?: { text: string }; thinking?: { text: string };
+ tool?: { name: string; phase?: string; argumentsPreview?: string | null; resultPreview?: string | null };
+ file?: { name?: string; relativePath?: string | null; operation?: string; changeCount?: number; snippet?: string | null };
+ publisher?: Json; metadata?: Json; source?: Json;
+}
+export interface InboxVisitor {
+ readonly token: string | null;
+ config(opts?: Opts): Promise<{ inbox: { id: string; name: string; greeting: string; color: string; ai: boolean; people: boolean } }>;
+ visit(opts?: Opts): Promise<{ token: string; verified?: boolean; account?: { name: string } | null; inbox: Json; conversation: Json | null; messages: InboxMessage[] }>;
+ send(message: string, extra?: { page_url?: string; name?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage }>;
+ messages(query?: { after?: number }, opts?: Opts): Promise<{ conversation: Json | null; messages: InboxMessage[] }>;
+ handoff(opts?: Opts): Promise<{ conversation: Json }>;
+ rate(rating: 1 | -1, extra?: { comment?: string; conversation?: string }, opts?: Opts): Promise<{ conversation: Json }>;
+ sendPicture(picture: Blob | ArrayBuffer | Uint8Array, extra?: { caption?: string; contentType?: string }, opts?: Opts): Promise<{ conversation: Json; message: InboxMessage; picture: { slug: string; direct: string; page: string } }>;
+}
+export interface FeedPublisher {
+ hello(input?: { sessionKey?: string; publisher?: Json }, opts?: Opts): Promise<{ sessionKey: string; publisher: Json }>;
+ publish(events: FeedEvent | FeedEvent[], opts?: Opts): Promise<{ events: { eventId: string; delivered: boolean }[] }>;
+ claudeCode(hook: Json, opts?: Opts): Promise<Json>;
}
}
export function createChatClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): ChatClient;
export function createChatClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): ChatClient;
+export function createInboxVisitor(client: OpenVibeClient, options: { inbox: string; token?: string | null; user?: { id: string; name?: string; email?: string; hash: string } | null; account?: string | null; baseUrl?: string }): InboxVisitor;
+export function createFeedPublisher(client: OpenVibeClient, options: { feed: string; key: string; baseUrl?: string }): FeedPublisher;