openvibers/openvibe.chat Commits

Support inboxes for teams: verified visitors, saved replies, webhooks (JSON, Slack, Discord), page context (#150)

OP OpenVibers committed 19 days ago · c0ee7e5e61 · 18 files, +607 −45

* Verified visitors: a site signs who its visitor is (HMAC of their id), the team sees their verified name and email, and their conversation follows them to every device

* Saved replies (picked with / in the reply box) and page context for Vibe

* Inbox webhooks: signed JSON for every event (openvibe-sdk/events parseDelivery checks it), a readable line for Slack and Discord when a visitor waits

* Inbox list previews skip system notes; addresses and paragraphs in forms get the full width

* docs/platform.md: what is built, what is next

* Webhooks: a visitor's words never ping a Slack channel; the outbound-request ratchet lists the webhook sender

Changes

ddiff --git a/README.md b/README.md
iindex 66385c4..38ff6e7 100644
---- a/README.md
++++ b/README.md
@@@ -452,6 +452,26 @@ for people and for companies, with "direct live support with an agent", "fully m
- **A site assistant** is an inbox whose people do not answer (`handoff` off in its settings): Vibe answers alone, there
is no "Talk to a person", Vibe is told nobody else answers (it points to the contact details in the instructions
instead of handing off), and when it cannot go on the visitor is told plainly. An inbox needs Vibe or its people.
++- **Verified visitors** (for a site with its own accounts): the owner turns them on in settings, which makes an
++ identity secret (`POST /api/chat/inboxes/:id/identity { action: on | rotate | off }`) to keep on their server. For a
++ signed-in visitor the site adds `data-user-id`, `data-user-name`, `data-user-email` and `data-user-hash` (the
++ HMAC-SHA256 of the id with the secret, in hex) to the tag, or calls `OpenVibeChat.identify({ id, name, email, hash })`.
++ When the hash checks out, Chat trusts the name and email, the team sees "verified" with the email, and the visitor's
++ token is derived from their id (`CHAT_INBOX_IDENTITY_KEY`, else Chat's OAuth secret; never stored), so their
++ conversation follows them to every device. A wrong or missing hash is just an anonymous visitor. Agents never see the
++ secret.
++- **Webhooks** (`server/inbox/webhooks.js`; up to three an inbox, the owner's; https on the public internet only):
++ any address gets every event as JSON, `{ event: { id, type, source: 'chat', occurred_at, data } }` with `type`
++ `chat.inbox.conversation.created | .waiting | .resolved | .rated` or `chat.inbox.message.created`. Each is signed like
++ an OpenVibe.Events delivery with the webhook's secret (shown once), so `parseDelivery(raw, headers, secret,
++ { requireV2: true })` from `openvibe-sdk/events` checks it. A Slack (`hooks.slack.com`) or Discord
++ (`discord.com/api/webhooks`) address gets a readable line instead, when a visitor waits for a person, writes while
++ waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each
++ webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks
++ { url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.
++- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE
++ /:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or
++ Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.
- **Ratings:** a resolved conversation asks the visitor "How was this?" (👍 / 👎, then a line if they like:
`POST /rating { token, rating: 1 | -1, comment?, conversation? }`); the people see it in the list and the
conversation, live (`inbox_rating`).
ddiff --git a/docs/platform.md b/docs/platform.md
iindex fbed501..1ea9440 100644
---- a/docs/platform.md
++++ b/docs/platform.md
@@@ -58,8 +58,11 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
3. **Community room** (built: `data-room`, bubble or inline, sign-in by ticket) **and site assistant** (built: an inbox
whose people do not answer) **modes** of the widget; pictures, video and files in the embed (signed-in
visitors first, then anonymous once Pics/Video take anonymous uploads); call escalation from a support conversation.
--4. **Developer platform**: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.conversation.*`, `chat.feed.*`),
-- `openvibe-sdk/chat`, Events types (`chat.conversation.created`, `.handoff_requested`, `.resolved`), webhooks.
--5. **Enterprise**: verified visitor identity (an HMAC of the site's user id, like Intercom's identity verification),
-- teams and routing, canned replies, CSAT, retention and export settings, translation, page-context sharing with the
-- visitor's permission (co-browsing-lite), procedures (Vibe calls a site's webhook to look up an order).
++4. **Developer platform**: `openvibe-sdk/chat` (built, SDK#78: inboxes, feeds, Vibe, `createInboxVisitor`,
++ `createFeedPublisher`), inbox webhooks (built: `chat.inbox.*` events signed like Events deliveries, Slack and
++ Discord lines). Next: capabilities in OpenVibe.Contracts (`chat.inbox.*`, `chat.feed.*`) so developer apps act for an
++ inbox's people, and the same events on OpenVibe.Events once it scopes event types to their owner.
++5. **Enterprise**: built: verified visitors (an HMAC of the site's user id, like Intercom's identity verification),
++ saved replies (the canned replies of other tools), ratings (CSAT), page context for Vibe, notifications. Next:
++ teams and routing, retention and export settings, translation, sharing the page with the visitor's permission
++ (co-browsing-lite), procedures (Vibe calls a site's webhook to look up an order).
ddiff --git a/migrations/0019_inbox_identity.sql b/migrations/0019_inbox_identity.sql
nnew file mode 100644
iindex 0000000..0d7407d
---- /dev/null
++++ b/migrations/0019_inbox_identity.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- Verified visitors and saved replies for support inboxes (docs/platform.md step 5). A site that knows who its signed-in
++-- visitor is passes their id with an HMAC of it made with the inbox's identity secret; Chat then trusts the name and
++-- email that come with it, and the visitor's conversation follows them to every device (server/inbox/inbox.js
++-- verifiedIdentity).
++ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS identity_secret text; -- null: verification off
++ALTER TABLE inbox_conversations ADD COLUMN IF NOT EXISTS external_id text; -- the site's id for the visitor, verified
++ALTER TABLE inbox_conversations ADD COLUMN IF NOT EXISTS visitor_email text;
++ALTER TABLE inbox_conversations ADD COLUMN IF NOT EXISTS verified smallint NOT NULL DEFAULT 0;
++CREATE INDEX IF NOT EXISTS inbox_conversations_external ON inbox_conversations (inbox_id, external_id) WHERE external_id IS NOT NULL;
++
++-- Saved replies: a team's ready answers, picked in the reply box with "/" and their shortcut.
++CREATE TABLE IF NOT EXISTS inbox_saved_replies (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ inbox_id text COLLATE "C" NOT NULL,
++ shortcut text COLLATE "C" NOT NULL, -- lowercase letters, digits and dashes
++ body text NOT NULL,
++ created_by bigint,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ UNIQUE (inbox_id, shortcut)
++);
ddiff --git a/migrations/0020_inbox_webhooks.sql b/migrations/0020_inbox_webhooks.sql
nnew file mode 100644
iindex 0000000..5312392
---- /dev/null
++++ b/migrations/0020_inbox_webhooks.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- An inbox's webhooks (server/inbox/webhooks.js): the team's own systems, Slack or Discord hear when a visitor waits for
++-- a person, writes, or rates a conversation. https on the public internet only; the secret signs the openvibe format.
++CREATE TABLE IF NOT EXISTS inbox_webhooks (
++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
++ inbox_id text COLLATE "C" NOT NULL,
++ url text NOT NULL,
++ format text COLLATE "C" NOT NULL DEFAULT 'openvibe' CHECK (format IN ('openvibe', 'slack', 'discord')),
++ secret text, -- whsec_…, the openvibe format only
++ created_by bigint,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ last_status integer,
++ last_error text,
++ last_at text COLLATE "C",
++ failures integer NOT NULL DEFAULT 0,
++ disabled_at text COLLATE "C",
++ UNIQUE (inbox_id, url)
++);
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 625ee29..a8375a4 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -73,6 +73,7 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-fieldset { border: 1px solid var(--oc-line); border-radius: 12px; padding: 8px 12px; display: grid; gap: 6px; }
.oc-form h2 { font-size: 1rem; margin: 18px 0 4px; }
.oc-form select { margin-left: 8px; max-width: calc(100% - 8px); } /* its margin counts too: a full-width select stuck 8 px out on phones */
++.oc-form input[type=url], .oc-form textarea { display: block; width: 100%; margin-top: 4px; } /* an address or a paragraph needs the width to be read */
@media (max-width: 560px) {
.oc-main { padding: 12px 12px 40px; }
.oc-feed { max-height: 60vh; }
ddiff --git a/public/web/embed.js b/public/web/embed.js
iindex a46974b..584d8df 100644
---- a/public/web/embed.js
++++ b/public/web/embed.js
@@@ -116,21 +116,37 @@
}
function post(path, body) {
-- return fetch(api + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ token: token }, body || {})) })
++ return fetch(api + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ token: token, user: identity || undefined }, body || {})) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: {} }; }); });
}
-- post('/visit', { token: store(function (s) { return s.getItem(KEY); }) }).then(function (x) {
-- if (!x.ok) { errorLine((x.d && x.d.error) || 'This chat is not available right now.'); return; }
-- token = x.d.token; store(function (s) { s.setItem(KEY, token); });
-- aiOn = !!(x.d.inbox && x.d.inbox.ai);
-- people = !(x.d.inbox && x.d.inbox.people === false);
-- if (x.d.inbox && x.d.inbox.color) toParent({ type: 'color', color: x.d.inbox.color });
-- conv = x.d.conversation;
-- (x.d.messages || []).forEach(render);
-- if (conv) setStatus(conv.status);
-- connect();
-- }).catch(function () { errorLine('You seem to be offline.'); });
++ // A site that knows its visitor passes a signed identity (widget.js data-user-*, OpenVibeChat.identify()); with ?id=1
++ // the frame waits for it (three seconds at most) before its first visit, and a later one starts the visit again: a
++ // verified person's token is their own, the same on every device, so their conversation follows them.
++ var identity = null, started = false, wantId = new URLSearchParams(location.search).get('id') === '1';
++ function begin() {
++ started = true;
++ var before = token;
++ post('/visit', { token: store(function (s) { return s.getItem(KEY); }) }).then(function (x) {
++ if (!x.ok) { errorLine((x.d && x.d.error) || 'This chat is not available right now.'); return; }
++ token = x.d.token; store(function (s) { s.setItem(KEY, token); });
++ aiOn = !!(x.d.inbox && x.d.inbox.ai);
++ people = !(x.d.inbox && x.d.inbox.people === false);
++ if (x.d.inbox && x.d.inbox.color) toParent({ type: 'color', color: x.d.inbox.color });
++ if (before && before !== token) Array.prototype.forEach.call(thread.querySelectorAll('[data-id], .em-rate'), function (n) { n.remove(); });
++ conv = x.d.conversation;
++ (x.d.messages || []).forEach(render);
++ if (conv) setStatus(conv.status);
++ if (!ws) connect(); else watch();
++ }).catch(function () { errorLine('You seem to be offline.'); });
++ }
++ window.addEventListener('message', function (e) {
++ if (e.source !== window.parent || !e.data || e.data.ov !== 'chat' || e.data.type !== 'identify') return;
++ var u = e.data.user;
++ identity = u && typeof u === 'object' && u.id && u.hash ? { id: String(u.id), name: u.name ? String(u.name) : undefined, email: u.email ? String(u.email) : undefined, hash: String(u.hash) } : null;
++ if (started || wantId) begin();
++ });
++ if (wantId) setTimeout(function () { if (!started) begin(); }, 3000); else begin();
function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; }
input.addEventListener('input', grow);
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex 7e21404..26a4747 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -97,7 +97,34 @@
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
}
++ // Saved replies: "/" at the start of the reply box lists them by shortcut; Enter, Tab or a click puts one in.
++ var saved = Array.isArray(cfg.saved) ? cfg.saved : [], pick = null, picked = 0;
++ function closePick() { if (pick) { pick.remove(); pick = null; } input && input.removeAttribute('aria-activedescendant'); }
++ function showPick() {
++ var m = /^\/([a-z0-9-]*)$/i.exec(input.value);
++ if (!m || !saved.length) { closePick(); return; }
++ var q = m[1].toLowerCase(), list = saved.filter(function (r) { return r.shortcut.indexOf(q) === 0; }).slice(0, 8);
++ if (!list.length) { closePick(); return; }
++ if (!pick) { pick = el('ul', 'ib-pick'); pick.setAttribute('role', 'listbox'); form.parentNode.insertBefore(pick, form); }
++ pick.textContent = ''; picked = Math.min(picked, list.length - 1);
++ list.forEach(function (r, i) {
++ var li = el('li', i === picked ? 'is-on' : ''); li.id = 'ib-pick-' + i; li.setAttribute('role', 'option');
++ li.appendChild(el('b', null, '/' + r.shortcut)); li.appendChild(el('span', null, r.body.slice(0, 90)));
++ li.addEventListener('mousedown', function (e) { e.preventDefault(); usePick(r); });
++ pick.appendChild(li);
++ });
++ pick._list = list; input.setAttribute('aria-activedescendant', 'ib-pick-' + picked);
++ }
++ function usePick(r) { input.value = r.body; closePick(); input.dispatchEvent(new Event('input')); input.focus(); }
++
if (form && input) {
++ input.addEventListener('input', function () { picked = 0; showPick(); });
++ input.addEventListener('keydown', function (e) {
++ if (!pick) return;
++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); picked = (picked + (e.key === 'ArrowDown' ? 1 : -1) + pick._list.length) % pick._list.length; showPick(); }
++ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopImmediatePropagation(); usePick(pick._list[picked]); }
++ else if (e.key === 'Escape') closePick();
++ });
var grow = function () { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + 'px'; };
input.addEventListener('input', grow);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.submit(); } });
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 2bd31f0..d02c9e1 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -217,6 +217,11 @@ body.oc-app-body { overflow: hidden; }
.ib-row b { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ib-row .vb-st { margin-left: 4px; }
.ib-rate { font-size: .85rem; line-height: 1; }
++.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
++.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }
++.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }
++.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
++.ib-pick li.is-on, .ib-pick li:hover { background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }
.ib-row time { margin-left: auto; font-size: .75rem; color: var(--oc-muted); flex: none; }
.ib-prev { color: var(--oc-muted); font-size: .84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ib-unread .ib-row b, .ib-unread .ib-prev { color: var(--oc-text); font-weight: 700; }
ddiff --git a/public/web/widget.js b/public/web/widget.js
iindex ff5af71..5f8db3e 100644
---- a/public/web/widget.js
++++ b/public/web/widget.js
@@@ -3,7 +3,9 @@
* <script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script> a public chat room
* data-position="left" puts the bubble on the left; data-color="#ff7a1a" colours it before the inbox's own colour loads;
* data-open="1" opens the panel at once. data-target="#chat" (a room) puts the room in that element instead of a
-- * bubble, data-height="480" tall.
++ * bubble, data-height="480" tall. A site that knows its visitor (an inbox with verified visitors) adds data-user-id,
++ * data-user-name, data-user-email and data-user-hash (HMAC-SHA256 of the id with the inbox's identity secret, made on
++ * the site's server), or calls OpenVibeChat.identify({ id, name, email, hash }) once they sign in.
* It draws a bubble in a shadow root (the site's CSS cannot reach it, it cannot reach the site's) and, on the first open,
* an iframe of openvibe.chat/embed/inbox/<id>: the conversation lives there, on openvibe.chat's own origin, so the site
* never sees the visitor's token and openvibe.chat never sees the site's cookies. The page may drive it:
@@@ -62,6 +64,10 @@
var bubble = root.querySelector('.b'), panel = root.querySelector('.p'), dot = root.querySelector('.d');
host.style.setProperty('--c', color); // custom properties cross into the shadow root (all:initial leaves them)
var frame = null, open = false;
++ var identity = null;
++ function identityOf(u) { return u && u.id && u.hash ? { id: String(u.id), name: u.name || undefined, email: u.email || undefined, hash: String(u.hash) } : null; }
++ if (!isRoom) identity = identityOf({ id: me.getAttribute('data-user-id'), name: me.getAttribute('data-user-name'), email: me.getAttribute('data-user-email'), hash: me.getAttribute('data-user-hash') });
++ function sendIdentity() { if (identity && frame && frame.contentWindow) frame.contentWindow.postMessage({ ov: 'chat', type: 'identify', user: identity }, base); }
function emit(name, detail) { try { window.dispatchEvent(new CustomEvent('openvibechat:' + name, { detail: detail })); } catch (e) { /* old browsers */ } }
function ensureFrame() {
@@@ -70,7 +76,8 @@
frame.title = 'Chat';
frame.allow = 'clipboard-write';
frame.src = isRoom ? base + '/embed/room/' + encodeURIComponent(room) + '?bubble=1'
-- : base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500));
++ : base + '/embed/inbox/' + inbox + '?page=' + encodeURIComponent(location.href.slice(0, 500)) + (identity ? '&id=1' : '');
++ frame.addEventListener('load', sendIdentity);
panel.appendChild(frame);
}
function setOpen(v) {
@@@ -92,7 +99,10 @@
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && open) setOpen(false); });
-- window.OpenVibeChat = { open: function () { setOpen(true); }, close: function () { setOpen(false); }, toggle: function () { setOpen(!open); } };
++ window.OpenVibeChat = {
++ open: function () { setOpen(true); }, close: function () { setOpen(false); }, toggle: function () { setOpen(!open); },
++ identify: function (u) { identity = identityOf(u); sendIdentity(); },
++ };
function mount() { document.body.appendChild(host); if (me.getAttribute('data-open') === '1') setOpen(true); }
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount);
})();
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 80883b5..fe7e252 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -111,6 +111,8 @@ const ERASE_AFTER_ROOMS = [
['inbox_conversations', 'DELETE FROM inbox_conversations WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inbox_members', 'DELETE FROM inbox_members WHERE user_id = ANY(@ids) OR inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
[null, 'DELETE FROM inbox_ai_usage WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
++ [null, 'DELETE FROM inbox_saved_replies WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
++ [null, 'DELETE FROM inbox_webhooks WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inboxes', 'DELETE FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)'],
[null, "UPDATE inbox_messages SET author_user_id = NULL, author_name = 'A former agent' WHERE author = 'agent' AND author_user_id = ANY(@ids)"],
// Feeds (server/feeds/feeds.js): a person's feeds go with everything published to them.
ddiff --git a/server/config.js b/server/config.js
iindex c629e17..2876baa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,7 +73,10 @@ module.exports = {
food: { internalUrl: strip(process.env.CHAT_FOOD_INTERNAL_URL || 'http://127.0.0.1:4970') },
// Support inboxes (server/inbox/inbox.js): Vibe answers at most this many visitors' messages per inbox a UTC day.
// ...and tells the inbox's people when a visitor waits for one of them (server/inbox/notify.js; 0 turns it off).
-- inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0' },
++ // A verified visitor's token is derived with CHAT_INBOX_IDENTITY_KEY (else Chat's OAuth secret): rotating it moves
++ // verified visitors to new conversations.
++ inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',
++ postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)) }, // a visitor's address, widget writes
// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex f8b9dc7..45f7770 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -148,6 +148,7 @@ async function mine(user) {
function shape(r) {
return {
id: r.id, name: r.name, greeting: r.greeting, color: r.color, ai_enabled: !!Number(r.ai_enabled), handoff: peopleAnswer(r), ai_instructions: r.ai_instructions,
++ verified_visitors: !!r.identity_secret,
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
};
}
@@@ -183,6 +184,7 @@ const convShape = (c) => c && ({
id: c.id, inbox_id: c.inbox_id, status: c.status, visitor_name: c.visitor_name || null, page_url: c.page_url || null,
assignee_id: c.assignee_id ? Number(c.assignee_id) : null, agent_unread: Number(c.agent_unread) || 0,
rating: c.rating == null ? null : Number(c.rating), rating_comment: c.rating_comment || null,
++ verified: !!Number(c.verified), visitor_email: c.visitor_email || null, external_id: c.external_id || null,
created_at: c.created_at, last_message_at: c.last_message_at || c.created_at,
});
const msgShape = (m) => m && ({ id: Number(m.id), conversation_id: m.conversation_id, author: m.author, author_name: m.author_name || null, body: m.body, created_at: m.created_at });
@@@ -193,6 +195,7 @@ async function addMessage(conv, { author, body, userId = null, name = null }, de
await db.run(`UPDATE inbox_conversations SET last_message_at = ov_now()${author === 'visitor' ? ', agent_unread = agent_unread + 1' : ''} WHERE id = ?`, [conv.id]);
const message = msgShape(row);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_message', inbox: conv.inbox_id, conversation: conv.id, message });
++ if (author !== 'system') require('./webhooks').later(conv.inbox_id, 'message.created', { conversationId: conv.id, message });
return message;
}
@@@ -200,7 +203,8 @@ async function setStatusRow(conv, status, deps = {}, extra = {}) {
await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,
extra.assignee ? [status, extra.assignee, conv.id] : [status, conv.id]);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_status', inbox: conv.inbox_id, conversation: conv.id, status });
-- if (status === 'waiting' && conv.status !== 'waiting') require('./notify').later(conv, 'waiting');
++ if (status === 'waiting' && conv.status !== 'waiting') { require('./notify').later(conv, 'waiting'); require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id }); }
++ if (status === 'resolved' && conv.status !== 'resolved') require('./webhooks').later(conv.inbox_id, 'conversation.resolved', { conversationId: conv.id });
return { ...conv, status };
}
@@@ -213,16 +217,85 @@ async function openConversationFor(inbox, token) {
ORDER BY created_at DESC LIMIT 1`, [inbox.id, hashToken(token)]) || null;
}
++// ── Verified visitors ──────────────────────────────────────────────────────────────────────────────────────────────
++
++/** Turn verification on (a new secret), make a new secret, or turn it off. The owner puts the secret on their server. */
++async function setIdentity(inbox, user, action) {
++ if ((await access(inbox, user)).role !== 'owner') fail(403, 'inbox.owner_only', 'Only the inbox\'s owner changes it');
++ if (!['on', 'rotate', 'off'].includes(action)) fail(422, 'inbox.identity', 'on, rotate or off');
++ const secret = action === 'off' ? null : action === 'on' && inbox.identity_secret ? inbox.identity_secret : crypto.randomBytes(32).toString('hex');
++ await db.run('UPDATE inboxes SET identity_secret = ? WHERE id = ?', [secret, inbox.id]);
++ return { identity_secret: secret };
++}
++
++/**
++ * A site's signed identity for its visitor: { id, name?, email?, hash } where hash is HMAC-SHA256(the inbox's identity
++ * secret, id) in hex, made on the site's server. → { id, name, email } when it checks out, else null (the visitor
++ * stays anonymous: a wrong or missing hash is not an error).
++ */
++function verifiedIdentity(inbox, user) {
++ if (!inbox.identity_secret || !user || typeof user !== 'object') return null;
++ const id = clean(user.id, 128);
++ const hash = String(user.hash || '').toLowerCase();
++ if (!id || !/^[0-9a-f]{64}$/.test(hash)) return null;
++ const want = crypto.createHmac('sha256', inbox.identity_secret).update(id).digest();
++ if (!crypto.timingSafeEqual(want, Buffer.from(hash, 'hex'))) return null;
++ const email = clean(user.email, 200);
++ return { id, name: clean(user.name, LIMITS.visitorName) || null, email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null };
++}
++/** The same visitor token on every device for a verified person, so their conversation follows them (never stored). */
++function identityToken(inbox, who) {
++ const key = config.inbox.identityKey || config.oauth.clientSecret || 'openvibe-chat-development';
++ return crypto.createHmac('sha256', key).update(`inbox-identity:${inbox.id}:${who.id}`).digest('base64url').slice(0, 32);
++}
++async function stampIdentity(convId, who) {
++ await db.run('UPDATE inbox_conversations SET external_id = ?, visitor_name = COALESCE(?, visitor_name), visitor_email = COALESCE(?, visitor_email), verified = 1 WHERE id = ?',
++ [who.id, who.name, who.email, convId]);
++}
++
++// ── Saved replies ──────────────────────────────────────────────────────────────────────────────────────────────────
++
++const SHORTCUT_RE = /^[a-z0-9][a-z0-9-]{0,29}$/;
++const MAX_SAVED = 100;
++const savedShape = (r) => ({ id: Number(r.id), shortcut: r.shortcut, body: r.body, created_by: r.created_by == null ? null : Number(r.created_by) });
++async function savedReplies(inbox) {
++ return (await db.all('SELECT * FROM inbox_saved_replies WHERE inbox_id = ? ORDER BY shortcut', [inbox.id])).map(savedShape);
++}
++/** Any of the inbox's people saves a reply; the same shortcut replaces it. */
++async function saveReply(inbox, user, { shortcut, body } = {}) {
++ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people save replies');
++ const key = String(shortcut || '').trim().toLowerCase().replace(/^\//, '');
++ if (!SHORTCUT_RE.test(key)) fail(422, 'inbox.shortcut', 'A shortcut is letters, digits and dashes, up to 30');
++ const text = clean(body, LIMITS.body);
++ if (!text) fail(422, 'inbox.empty', 'Write the reply');
++ const n = (await db.get('SELECT COUNT(*) AS n FROM inbox_saved_replies WHERE inbox_id = ?', [inbox.id])).n;
++ const exists = await db.get('SELECT 1 FROM inbox_saved_replies WHERE inbox_id = ? AND shortcut = ?', [inbox.id, key]);
++ if (!exists && Number(n) >= MAX_SAVED) fail(429, 'inbox.too_many_saved', `At most ${MAX_SAVED} saved replies`);
++ await db.run(`INSERT INTO inbox_saved_replies (inbox_id, shortcut, body, created_by) VALUES (?, ?, ?, ?)
++ ON CONFLICT (inbox_id, shortcut) DO UPDATE SET body = excluded.body, created_by = excluded.created_by`, [inbox.id, key, text, user.id]);
++ return await savedReplies(inbox);
++}
++async function deleteReply(inbox, user, id) {
++ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people change saved replies');
++ await db.run('DELETE FROM inbox_saved_replies WHERE inbox_id = ? AND id = ?', [inbox.id, Number(id) || 0]);
++ return await savedReplies(inbox);
++}
++
// ── Visitors ───────────────────────────────────────────────────────────────────────────────────────────────────────
--/** A visitor opens the widget: their token (a new one when theirs is missing or malformed) and their open conversation. */
--async function visit(inbox, token) {
-- const t = TOKEN_RE.test(String(token || '')) ? String(token) : newToken();
-- const conv = await openConversationFor(inbox, t);
-- return { token: t, conversation: convShape(conv), messages: conv ? await messages(conv) : [] };
++/**
++ * A visitor opens the widget: their token (a new one when theirs is missing or malformed; a verified person's own, the
++ * same on every device) and their open conversation.
++ */
++async function visit(inbox, token, user = null) {
++ const who = verifiedIdentity(inbox, user);
++ const t = who ? identityToken(inbox, who) : TOKEN_RE.test(String(token || '')) ? String(token) : newToken();
++ let conv = await openConversationFor(inbox, t);
++ if (conv && who) { await stampIdentity(conv.id, who); conv = await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conv.id]); }
++ return { token: t, verified: !!who, conversation: convShape(conv), messages: conv ? await messages(conv) : [] };
}
--async function visitorPost(inbox, token, { message, page_url = null, name = null } = {}, deps = {}) {
++async function visitorPost(inbox, token, { message, page_url = null, name = null, user = null } = {}, deps = {}) {
if (!TOKEN_RE.test(String(token || ''))) fail(401, 'inbox.visitor', 'Open the chat again to start a conversation');
const body = clean(message, LIMITS.body);
if (!body) fail(422, 'inbox.empty', 'Write something first');
@@@ -233,8 +306,12 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
const id = newId('cnv');
await db.run(`INSERT INTO inbox_conversations (id, inbox_id, visitor_token_hash, visitor_name, status, page_url, last_message_at)
VALUES (?, ?, ?, ?, ?, ?, ov_now())`, [id, inbox.id, hashToken(token), clean(name, LIMITS.visitorName) || null, Number(inbox.ai_enabled) ? 'bot' : 'waiting', pageUrlOf(page_url)]);
++ // A verified person's conversation carries who they are (only with their own token: an id is not enough).
++ const who = verifiedIdentity(inbox, user);
++ if (who && identityToken(inbox, who) === String(token)) await stampIdentity(id, who);
conv = await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [id]);
broadcaster(deps)(inbox.id, conv.id, { type: 'inbox_conversation', inbox: inbox.id, conversation: convShape(conv) });
++ require('./webhooks').later(inbox.id, 'conversation.created', { conversationId: conv.id });
}
const filtered = wordFilter.check(body);
const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);
@@@ -277,6 +354,7 @@ async function rate(inbox, token, { rating, comment = '', conversation: convId =
await db.run('UPDATE inbox_conversations SET rating = ?, rating_comment = ?, rated_at = ov_now() WHERE id = ?', [r, (filtered.safe ? clean(comment, 500) : filtered.filtered) || null, conv.id]);
const shaped = convShape(await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conv.id]));
broadcaster(deps)(inbox.id, null, { type: 'inbox_rating', inbox: inbox.id, conversation: conv.id, rating: shaped.rating, comment: shaped.rating_comment });
++ require('./webhooks').later(inbox.id, 'conversation.rated', { conversationId: conv.id });
return shaped;
}
@@@ -285,7 +363,7 @@ async function rate(inbox, token, { rating, comment = '', conversation: convId =
async function conversations(inbox, { status = null, limit = 100 } = {}) {
const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : STATUSES.includes(status) ? 'AND c.status = ?' : '';
const params = [inbox.id, ...(STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];
-- const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id ORDER BY m.id DESC LIMIT 1) AS last_body
++ const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id AND m.author <> 'system' ORDER BY m.id DESC LIMIT 1) AS last_body
FROM inbox_conversations c WHERE c.inbox_id = ? ${where}
ORDER BY CASE c.status WHEN 'waiting' THEN 0 WHEN 'open' THEN 1 WHEN 'bot' THEN 2 ELSE 3 END, COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, params);
return rows.map((r) => ({ ...convShape(r), preview: plainText(r.last_body).slice(0, 140) }));
@@@ -380,7 +458,9 @@ async function aiAnswer(inbox, convId, deps = {}) {
.map((m) => ({ role: m.author === 'visitor' ? 'user' : 'assistant', content: `${m.author === 'agent' ? `(${m.author_name || 'a person'} from ${inbox.name}) ` : ''}${String(m.body).slice(0, 2000)}` }));
const tools = inboxTools(inbox);
const aiClient = deps.aiClient || require('../ai/client');
-- const messagesForModel = [{ role: 'system', content: inboxPrompt(inbox) }, ...transcript];
++ // What Vibe may know about where the visitor is and who they are (a verified name only: anyone can type a name).
++ const context = [conv.page_url ? `The visitor is on this page: ${conv.page_url}` : '', Number(conv.verified) && conv.visitor_name ? `${inbox.name}'s site says the visitor is ${conv.visitor_name}.` : ''].filter(Boolean).join('\n');
++ const messagesForModel = [{ role: 'system', content: inboxPrompt(inbox) + (context ? `\n\n${context}` : '') }, ...transcript];
const lastId = history.length ? history[history.length - 1].id : 0;
let text = '';
for (let round = 0; round <= 3 && !text; round++) {
@@@ -413,6 +493,6 @@ function _reset() { inFlight.clear(); }
module.exports = {
InboxError, INBOX_RE, CONV_RE, LIMITS, MAX_INBOXES, AI_PER_CONVERSATION, hashToken,
create, update, remove, get, access, mine, members, addMember, removeMember, shape, publicConfig, plainText, peopleAnswer,
-- visit, visitorPost, handoff, rate, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
++ visit, visitorPost, handoff, rate, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
aiAnswer, inboxPrompt, _reset,
};
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex b488847..483b270 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -5,9 +5,10 @@
* /api/chat/widget/inbox/:id — the visitor's side, what openvibe.chat/embed/inbox/:id (the widget's panel) calls; no
* sign-in, the visitor's token in the body (or X-Visitor-Token):
* GET / { inbox }: name, greeting, color, whether Vibe answers
-- * POST /visit { token? } → { token, inbox, conversation, messages } (a new token when theirs is missing)
++ * POST /visit { token?, user? } → { token, verified, inbox, conversation, messages } (a new token when theirs is
++ * missing; user = { id, name?, email?, hash } signed by the site: a verified person's own token)
* GET /messages ?token=&after= → { conversation, messages }
-- * POST /messages { token, message, page_url?, name? } → 201 { conversation, message }
++ * POST /messages { token, message, page_url?, name?, user? } → 201 { conversation, message }
* POST /handoff { token } → { conversation } ("Talk to a person"; 409 when only Vibe answers)
* POST /rating { token, rating: 1 | -1, comment?, conversation? } → { conversation } (else their latest resolved)
*
@@@ -16,6 +17,10 @@
* POST / { name, greeting?, color?, ai_enabled?, ai_instructions?, knowledge_urls?, allowed_origins? } → 201
* GET /:id { inbox, members, embed } PATCH /:id (owner) DELETE /:id (owner)
* POST /:id/members { username } (owner) DELETE /:id/members/:userId (owner)
++ * POST /:id/identity { action: on | rotate | off } (owner) → { identity_secret }: verified visitors
++ * GET /:id/replies { replies }: saved replies POST /:id/replies { shortcut, body } DELETE /:id/replies/:replyId
++ * GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
++ * POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* GET /:id/conversations ?status=active|bot|waiting|open|resolved
* GET /:id/conversations/:cid/messages ?after=
* POST /:id/conversations/:cid/messages { message } → 201: a person's answer (the conversation becomes open)
@@@ -29,9 +34,10 @@ const config = require('../config');
const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const inboxes = require('./inbox');
++const webhooks = require('./webhooks');
function send(res, err) {
-- if (err instanceof inboxes.InboxError) return res.status(err.status).json({ error: err.message, code: err.code });
++ if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Inbox]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
}
@@@ -41,7 +47,7 @@ const tokenOf = (req) => String((req.body && req.body.token) || req.get('x-visit
// ── Visitors ───────────────────────────────────────────────────────────────────────────────────────────────────────
const widget = express.Router({ mergeParams: true });
const visitLimit = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
--const postLimit = rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
++const postLimit = rateLimit({ windowMs: 60_000, max: () => config.inbox.postsPerMinute, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
// An inbox that names its sites answers only them (and its own panel on openvibe.chat); a call with no Origin (a server)
// is let through, since a browser always sends one across sites.
@@@ -61,7 +67,7 @@ widget.get('/', visitLimit, async (req, res) => {
});
widget.post('/visit', visitLimit, async (req, res) => {
const inbox = await inboxOr404(req, res); if (!inbox) return;
-- try { noStore(res).json({ inbox: inboxes.publicConfig(inbox), ...(await inboxes.visit(inbox, tokenOf(req))) }); } catch (err) { send(res, err); }
++ try { noStore(res).json({ inbox: inboxes.publicConfig(inbox), ...(await inboxes.visit(inbox, tokenOf(req), req.body && req.body.user)) }); } catch (err) { send(res, err); }
});
widget.get('/messages', visitLimit, async (req, res) => {
const inbox = await inboxOr404(req, res); if (!inbox) return;
@@@ -126,6 +132,38 @@ agents.patch('/:id', requireAuth, manageLimit, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
try { res.json({ inbox: inboxes.shape(await inboxes.update(inbox, req.user, req.body || {})) }); } catch (err) { send(res, err); }
});
++agents.get('/:id/replies', requireAuth, async (req, res) => {
++ const inbox = await agentInbox(req, res); if (!inbox) return;
++ noStore(res).json({ replies: await inboxes.savedReplies(inbox) });
++});
++agents.post('/:id/replies', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res); if (!inbox) return;
++ try { res.json({ replies: await inboxes.saveReply(inbox, req.user, req.body || {}) }); } catch (err) { send(res, err); }
++});
++agents.delete('/:id/replies/:replyId', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res); if (!inbox) return;
++ try { res.json({ replies: await inboxes.deleteReply(inbox, req.user, req.params.replyId) }); } catch (err) { send(res, err); }
++});
++agents.get('/:id/webhooks', requireAuth, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ noStore(res).json({ webhooks: await webhooks.list(inbox) });
++});
++agents.post('/:id/webhooks', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ try { noStore(res).status(201).json(await webhooks.add(inbox, req.user, req.body && req.body.url)); } catch (err) { send(res, err); }
++});
++agents.post('/:id/webhooks/:hookId/test', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ try { res.json(await webhooks.test(inbox, req.params.hookId)); } catch (err) { send(res, err); }
++});
++agents.delete('/:id/webhooks/:hookId', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ await webhooks.remove(inbox, req.params.hookId); res.status(204).end();
++});
++agents.post('/:id/identity', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ try { noStore(res).json(await inboxes.setIdentity(inbox, req.user, String((req.body && req.body.action) || ''))); } catch (err) { send(res, err); }
++});
agents.delete('/:id', requireAuth, manageLimit, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
try { await inboxes.remove(inbox, req.user); res.status(204).end(); } catch (err) { send(res, err); }
ddiff --git a/server/inbox/webhooks.js b/server/inbox/webhooks.js
nnew file mode 100644
iindex 0000000..0580fac
---- /dev/null
++++ b/server/inbox/webhooks.js
@@@ -0,0 +1,171 @@
++'use strict';
++/**
++ * An inbox's webhooks (server/inbox/inbox.js; migrations/0020_inbox_webhooks.sql): the team's own systems hear what
++ * happens in its conversations. Up to three addresses an inbox, https only, on the public internet (openvibe-shared/
++ * egress: every DNS answer must be public, no redirects followed).
++ *
++ * openvibe every event as JSON, { event: { id, type, source: 'chat', occurred_at, data } }, signed like OpenVibe.Events
++ * deliveries (X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 with the webhook's secret, shown once:
++ * openvibe-sdk/events parseDelivery checks it and returns { event }):
++ * chat.inbox.conversation.created · .conversation.waiting · .conversation.resolved · .conversation.rated ·
++ * .message.created
++ * slack (hooks.slack.com) and discord (discord.com/api/webhooks): a readable line when a visitor waits for a
++ * person, writes while waiting (at most once a conversation every 10 minutes) or rates a conversation
++ *
++ * Delivery is fire and forget, three tries (now, after 30 seconds, after 5 minutes); each webhook keeps its last answer,
++ * and one failing 20 times in a row is switched off (the owner switches it back on by saving it again).
++ */
++const crypto = require('crypto');
++const https = require('https');
++const db = require('../db/database');
++const config = require('../config');
++
++const MAX_WEBHOOKS = 3;
++const RETRIES = [0, 30_000, 5 * 60_000];
++const QUIET_MS = 10 * 60 * 1000;
++const FAIL_OFF = 20;
++const EVENTS = ['conversation.created', 'conversation.waiting', 'conversation.resolved', 'conversation.rated', 'message.created'];
++const chatty = new Map(); // `${webhook}:${conversation}` → last line sent to Slack or Discord
++let _send = null; // tests: (url, body, headers) → { status }
++let _lookup = null; // tests: a dns.lookup stand-in for the address check
++
++class WebhookError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const fail = (status, code, message) => { throw new WebhookError(status, code, message); };
++
++function formatOf(u) {
++ if (u.hostname === 'hooks.slack.com') return 'slack';
++ if ((u.hostname === 'discord.com' || u.hostname === 'discordapp.com') && u.pathname.startsWith('/api/webhooks/')) return 'discord';
++ return 'openvibe';
++}
++const shape = (r) => ({
++ id: Number(r.id), url: r.url, format: r.format, last_status: r.last_status == null ? null : Number(r.last_status),
++ last_error: r.last_error || null, last_at: r.last_at || null, failures: Number(r.failures) || 0, on: !r.disabled_at, created_at: r.created_at,
++});
++
++async function list(inbox) {
++ return (await db.all('SELECT * FROM inbox_webhooks WHERE inbox_id = ? ORDER BY id', [inbox.id])).map(shape);
++}
++
++/** Add (or switch back on) an address. → { webhook, secret } (the secret only for the openvibe format, only now). */
++async function add(inbox, user, url, deps = {}) {
++ const { assertPublicUrl } = require('openvibe-shared/egress');
++ let u;
++ const lookup = deps.lookup || _lookup;
++ try { u = await assertPublicUrl(String(url || '').trim(), { protocols: ['https:'], ...(lookup ? { lookup } : {}) }); }
++ catch (err) { fail(422, 'inbox.webhook_url', `That address cannot be used: ${err.message}`); }
++ const href = u.href.slice(0, 1000);
++ const existing = await db.get('SELECT * FROM inbox_webhooks WHERE inbox_id = ? AND url = ?', [inbox.id, href]);
++ if (existing) {
++ await db.run('UPDATE inbox_webhooks SET disabled_at = NULL, failures = 0 WHERE id = ?', [existing.id]);
++ return { webhook: shape(await db.get('SELECT * FROM inbox_webhooks WHERE id = ?', [existing.id])), secret: null };
++ }
++ const n = (await db.get('SELECT COUNT(*) AS n FROM inbox_webhooks WHERE inbox_id = ?', [inbox.id])).n;
++ if (Number(n) >= MAX_WEBHOOKS) fail(429, 'inbox.too_many_webhooks', `At most ${MAX_WEBHOOKS} webhooks an inbox`);
++ const format = formatOf(u);
++ const secret = format === 'openvibe' ? `whsec_${crypto.randomBytes(24).toString('base64url')}` : null;
++ const row = await db.get(`INSERT INTO inbox_webhooks (inbox_id, url, format, secret, created_by) VALUES (?, ?, ?, ?, ?) RETURNING *`,
++ [inbox.id, href, format, secret, user.id]);
++ return { webhook: shape(row), secret };
++}
++async function remove(inbox, id) { await db.run('DELETE FROM inbox_webhooks WHERE inbox_id = ? AND id = ?', [inbox.id, Number(id) || 0]); }
++
++// ── Delivery ───────────────────────────────────────────────────────────────────────────────────────────────────────
++
++function post(url, body, headers) {
++ if (_send) return _send(url, body, headers);
++ const { safeLookup } = require('openvibe-shared/egress');
++ return new Promise((resolve) => {
++ const req = https.request(url, { method: 'POST', lookup: safeLookup, timeout: 8000, headers: { 'content-type': 'application/json', 'user-agent': 'OpenVibe-Chat-Webhook/1', 'content-length': Buffer.byteLength(body), ...headers } }, (res) => {
++ res.resume();
++ resolve({ status: res.statusCode });
++ });
++ req.on('timeout', () => req.destroy(new Error('timed out')));
++ req.on('error', (err) => resolve({ status: 0, error: err.message }));
++ req.end(body);
++ });
++}
++
++async function attempt(hook, body, headers, tryNo = 0) {
++ const r = await post(hook.url, body, headers);
++ const ok = r.status >= 200 && r.status < 300;
++ await db.run(`UPDATE inbox_webhooks SET last_status = ?, last_error = ?, last_at = ov_now(), failures = ?${!ok && Number(hook.failures) + 1 >= FAIL_OFF ? ', disabled_at = ov_now()' : ''} WHERE id = ?`,
++ [r.status || null, ok ? null : (r.error || `HTTP ${r.status}`).slice(0, 200), ok ? 0 : Number(hook.failures) + 1, hook.id]).catch(() => {});
++ hook.failures = ok ? 0 : Number(hook.failures) + 1;
++ if (!ok && tryNo + 1 < RETRIES.length && !(r.status >= 400 && r.status < 500 && r.status !== 429)) {
++ setTimeout(() => attempt(hook, body, headers, tryNo + 1).catch(() => {}), RETRIES[tryNo + 1]).unref();
++ }
++ return ok;
++}
++
++/** A readable line for Slack or Discord, or null when this event is not news there. */
++function lineFor(hook, type, d) {
++ const who = (d.conversation && d.conversation.visitor_name) || 'A visitor';
++ const quote = (t) => `“${String(t || '').replace(/\s+/g, ' ').slice(0, 300)}”`;
++ const link = d.url;
++ const key = `${hook.id}:${d.conversation && d.conversation.id}`;
++ if (type === 'conversation.waiting') { chatty.set(key, Date.now()); return `🛟 ${who} is waiting for a person in ${d.inbox.name}${d.last ? `: ${quote(d.last)}` : ''}\n${link}`; }
++ if (type === 'conversation.rated') return `${d.conversation.rating > 0 ? '👍' : '👎'} ${who} rated their conversation with ${d.inbox.name}${d.conversation.rating_comment ? `: ${quote(d.conversation.rating_comment)}` : ''}\n${link}`;
++ if (type === 'message.created' && d.message.author === 'visitor' && ['waiting', 'open'].includes(d.conversation.status)) {
++ if (Date.now() - (chatty.get(key) || 0) < QUIET_MS) return null;
++ chatty.set(key, Date.now());
++ if (chatty.size > 5000) chatty.delete(chatty.keys().next().value);
++ return `💬 ${who} wrote in ${d.inbox.name}: ${quote(d.message.body)}\n${link}`;
++ }
++ return null;
++}
++
++/**
++ * Tell an inbox's webhooks about an event in a conversation (and the message, for message.created). The conversation
++ * is read as it is now, with the visitor's last line. Never throws and never waits for the deliveries.
++ */
++async function emit(inboxId, type, { conversationId = null, message = null } = {}) {
++ if (!EVENTS.includes(type)) return 0;
++ const hooks = await db.all('SELECT * FROM inbox_webhooks WHERE inbox_id = ? AND disabled_at IS NULL', [inboxId]).catch(() => []);
++ if (!hooks.length) return 0;
++ const inbox = await db.get('SELECT id, name FROM inboxes WHERE id = ?', [inboxId]);
++ if (!inbox) return 0;
++ const conv = conversationId ? await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conversationId]) : null;
++ const last = conv ? await db.get("SELECT body FROM inbox_messages WHERE conversation_id = ? AND author = 'visitor' ORDER BY id DESC LIMIT 1", [conv.id]) : null;
++ const d = {
++ conversation: conv ? require('./inbox').convShape(conv) : null, ...(message ? { message } : {}), last: last ? last.body : null,
++ inbox: { id: inbox.id, name: inbox.name }, url: `${config.web.baseUrl}/inbox/${inbox.id}${conv ? `?c=${conv.id}` : ''}`,
++ };
++ let sent = 0;
++ for (const hook of hooks) {
++ let body; let headers = {};
++ if (hook.format === 'openvibe') {
++ body = JSON.stringify({ event: { id: `evt_${crypto.randomBytes(12).toString('hex')}`, type: `chat.inbox.${type}`, source: 'chat', occurred_at: new Date().toISOString(), data: d } });
++ const { signDeliveryHeaders } = require('openvibe-sdk/events');
++ headers = signDeliveryHeaders(body, hook.secret);
++ } else {
++ const line = lineFor(hook, type, d);
++ if (!line) continue;
++ // What a visitor wrote never pings anyone: Slack's control characters are escaped (so "<!channel>" is text),
++ // and Discord is told to parse no mentions.
++ const slackSafe = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
++ body = JSON.stringify(hook.format === 'slack' ? { text: slackSafe(line) } : { content: line.slice(0, 1900), allowed_mentions: { parse: [] } });
++ }
++ attempt(hook, body, headers).catch(() => {});
++ sent++;
++ }
++ return sent;
++}
++function later(inboxId, type, data) { emit(inboxId, type, data).catch((err) => console.warn(`[Inbox] webhook: ${err && err.message}`)); }
++
++/** A test event, now: → { ok, status, error } */
++async function test(inbox, id) {
++ const hook = await db.get('SELECT * FROM inbox_webhooks WHERE inbox_id = ? AND id = ?', [inbox.id, Number(id) || 0]);
++ if (!hook) fail(404, 'inbox.no_webhook', 'No such webhook');
++ const line = `✅ OpenVibe Chat: the "${inbox.name}" inbox can reach this webhook.`;
++ const body = hook.format === 'openvibe' ? JSON.stringify({ event: { id: `evt_${crypto.randomBytes(12).toString('hex')}`, type: 'chat.inbox.test', source: 'chat', occurred_at: new Date().toISOString(), data: { inbox: { id: inbox.id, name: inbox.name } } } })
++ : JSON.stringify(hook.format === 'slack' ? { text: line } : { content: line });
++ const headers = hook.format === 'openvibe' ? require('openvibe-sdk/events').signDeliveryHeaders(body, hook.secret) : {};
++ const r = await post(hook.url, body, headers);
++ const ok = r.status >= 200 && r.status < 300;
++ await db.run('UPDATE inbox_webhooks SET last_status = ?, last_error = ?, last_at = ov_now() WHERE id = ?', [r.status || null, ok ? null : (r.error || `HTTP ${r.status}`).slice(0, 200), hook.id]);
++ return { ok, status: r.status || null, error: ok ? null : (r.error || `HTTP ${r.status}`) };
++}
++
++function _setSend(fn, lookup = null) { _send = fn; _lookup = lookup; chatty.clear(); }
++
++module.exports = { WebhookError, EVENTS, MAX_WEBHOOKS, list, add, remove, emit, later, test, formatOf, _setSend };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 67f1486..aef94a3 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -15,6 +15,7 @@
const path = require('path');
const md = require('../../public/web/vibe-md.js');
const inboxes = require('../inbox/inbox');
++const webhooks = require('../inbox/webhooks');
const { embedOf } = require('../inbox/routes');
const { esc, asset } = require('./layout');
@@@ -120,10 +121,11 @@ ${list}
const filter = ['active', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
const list = await inboxes.conversations(inbox, { status: filter });
const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
++ const saved = await inboxes.savedReplies(inbox);
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&amp;f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}">
--<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
++<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
<span class="ib-prev">${esc(c.preview || '')}</span></a></li>`;
const turn = (m) => {
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
@@@ -145,19 +147,19 @@ ${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 're
<div class="vb-scrim" data-vb-close hidden></div>
<section class="vb-main" aria-label="Conversation">
<header class="vb-head"><button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show conversations">&#9776;</button>
--<h1 class="vb-title">${current ? `${esc(current.visitor_name || 'Visitor')} ${statusChip(current.status)}${current.rating ? ` <span class="ib-rate" title="${esc(current.rating_comment || 'The visitor\'s rating')}">${Number(current.rating) > 0 ? '👍' : '👎'}</span>` : ''}` : esc(inbox.name)}</h1>${actions}</header>
++<h1 class="vb-title">${current ? `${esc(current.visitor_name || 'Visitor')}${Number(current.verified) ? ' <span class="ib-ok" title="Verified by your site">✓</span>' : ''} ${statusChip(current.status)}${current.rating ? ` <span class="ib-rate" title="${esc(current.rating_comment || 'The visitor\'s rating')}">${Number(current.rating) > 0 ? '👍' : '👎'}</span>` : ''}` : esc(inbox.name)}</h1>${actions}</header>
<div class="vb-scroll" id="vb-scroll">
--${current ? `${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`
++${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}</p>` : ''}${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`
: `<div class="vb-hello"><h1>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h1><p class="vb-sub">${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`}
</div>
${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
--<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}" required></textarea>
++<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>
<button class="vb-send" type="submit" aria-label="Send">&#8593;</button></form><p class="vb-fine">Replying takes the conversation from Vibe.</p></div>` : ''}
</section>
</div>`;
await page(req, res, 200, {
actor, app: true, appScript: 'inbox.js', active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
-- page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter },
++ page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
});
});
@@@ -182,6 +184,8 @@ ${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" met
const team = await inboxes.members(inbox);
const embed = embedOf(inbox);
const owner = role === 'owner';
++ const saved = await inboxes.savedReplies(inbox);
++ const hooks = owner ? await webhooks.list(inbox) : [];
await page(req, res, 200, {
actor, title: `${inbox.name}: settings`, path: `/inbox/${inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
body: `<p><a href="/inbox/${esc(inbox.id)}">&larr; ${esc(inbox.name)}</a></p><h1>${esc(inbox.name)}</h1>
@@@ -190,7 +194,27 @@ ${req.query.made ? notice('ok', 'Your inbox is ready. Paste the snippet into you
<p>Paste this before <code>&lt;/body&gt;</code> on every page that should show the chat bubble:</p>
<pre class="ib-snippet"><code>${esc(embed.script)}</code></pre>
<p class="oc-muted">Your page can open it with <code>OpenVibeChat.open()</code>. Try it: <a href="${esc(embed.panel)}" target="_blank" rel="noopener">open the chat panel</a>.</p>
--${owner ? `<h2>How it looks and what Vibe knows</h2>
++${owner ? `<h2>Verified visitors</h2>
++${shape.verified_visitors ? `<p>On. Your site signs who its signed-in visitor is, and Chat trusts the name and email that come with it; their conversation follows them to every device. Keep this secret on your server only:</p>
++<pre class="ib-snippet"><code>${esc(inbox.identity_secret)}</code></pre>
++<p>For a signed-in visitor, add <code>data-user-id</code>, <code>data-user-name</code>, <code>data-user-email</code> and <code>data-user-hash</code> to the tag (or call <code>OpenVibeChat.identify({ id, name, email, hash })</code>), where the hash is the HMAC-SHA256 of the id with the secret, in hex:</p>
++<pre class="ib-snippet"><code>${esc(`// Node
++crypto.createHmac('sha256', SECRET).update(String(user.id)).digest('hex')
++# Python
++hmac.new(SECRET.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
++// PHP
++hash_hmac('sha256', (string) $user->id, $secret)`)}</code></pre>
++<form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/identity"><button name="action" value="rotate">Make a new secret</button></form>
++<form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/identity"><button class="oc-link" name="action" value="off">Turn it off</button></form>`
++ : `<p class="oc-muted">Off: every visitor is anonymous to you unless they give a name. When your site has its own accounts, turn this on and your server signs who each visitor is; you see their verified name and email, and they find their conversation on any device.</p>
++<form method="post" action="/inbox/${esc(inbox.id)}/identity"><button name="action" value="on">Turn on verified visitors</button></form>`}
++<h2>Webhooks</h2>
++<p class="oc-muted">Your systems hear what happens: a Slack or Discord webhook address gets a line when a visitor waits for a person, writes while waiting, or rates a conversation; any other https address gets every event as signed JSON (verify it with <code>openvibe-sdk/events</code> <code>parseDelivery</code>).</p>
++${hooks.length ? `<ul class="oc-members">${hooks.map((w) => `<li><b>${esc(w.format === 'openvibe' ? 'JSON' : w.format === 'slack' ? 'Slack' : 'Discord')}</b> <span class="oc-muted">${esc(w.url.replace(/^https:\/\//, '').slice(0, 60))}${w.url.length > 68 ? '…' : ''} · ${w.on ? (w.last_status ? `last answer ${w.last_status}${w.last_error ? ` (${esc(w.last_error)})` : ''}` : 'nothing sent yet') : 'off after repeated failures: add it again to switch it on'}</span>
++<form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/webhooks/${w.id}/test"><button class="oc-link" type="submit">Send a test</button></form>
++<form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/webhooks/${w.id}/delete"><button class="oc-link" type="submit">Remove</button></form></li>`).join('')}</ul>` : ''}
++${hooks.length < webhooks.MAX_WEBHOOKS ? `<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/webhooks"><label>Address (https) <input name="url" type="url" required maxlength="1000" placeholder="https://hooks.slack.com/services/…"></label><p><button type="submit">Add the webhook</button></p></form>` : ''}
++<h2>How it looks and what Vibe knows</h2>
<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/settings">
<label>Name <input name="name" required minlength="2" maxlength="60" value="${esc(shape.name)}"></label>
<label>Greeting <input name="greeting" maxlength="300" value="${esc(shape.greeting)}"></label>
@@@ -203,7 +227,12 @@ ${owner ? `<h2>How it looks and what Vibe knows</h2>
<p><button type="submit">Save</button></p></form>` : '<p class="oc-muted">Only the owner changes the settings.</p>'}
<h2>People</h2>
<ul class="oc-members">${team.map((m) => `<li><b>${esc(m.display_name || m.username || `user ${m.user_id}`)}</b> <span class="oc-muted">@${esc(m.username || '')} · ${m.role}</span>${owner && m.role !== 'owner' ? ` <form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/members/${m.user_id}/delete"><button class="oc-link" type="submit">Remove</button></form>` : ''}</li>`).join('')}</ul>
--${owner ? `<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/members"><label>Add a person by username <input name="username" required maxlength="40" placeholder="username"></label><p><button type="submit">Add</button></p></form>` : ''}`,
++${owner ? `<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/members"><label>Add a person by username <input name="username" required maxlength="40" placeholder="username"></label><p><button type="submit">Add</button></p></form>` : ''}
++<h2>Saved replies</h2>
++<p class="oc-muted">Ready answers for the whole team: type <code>/</code> in the reply box and its shortcut.</p>
++${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.shortcut)}</b> <span class="oc-muted">${esc(r.body.slice(0, 120))}${r.body.length > 120 ? '…' : ''}</span> <form class="oc-inline" method="post" action="/inbox/${esc(inbox.id)}/replies/${r.id}/delete"><button class="oc-link" type="submit">Delete</button></form></li>`).join('')}</ul>` : ''}
++<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/replies"><label>Shortcut <input name="shortcut" required maxlength="30" pattern="/?[a-zA-Z0-9][a-zA-Z0-9-]*" placeholder="refund"></label>
++<label>Reply <textarea name="body" rows="3" maxlength="4000" required placeholder="Refunds go back to the card you paid with within 5 days."></textarea></label><p><button type="submit">Save the reply</button></p></form>`,
});
});
@@@ -215,6 +244,44 @@ ${owner ? `<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/m
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
} catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }
});
++ router.post('/inbox/:id/webhooks', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}/settings`);
++ try {
++ const { secret } = await webhooks.add(a.inbox, a.actor.user, req.body && req.body.url);
++ if (!secret) return res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
++ // The secret is shown on this answer only (never in a URL: addresses end up in histories and logs).
++ await page(req, res, 201, { actor: a.actor, title: `${a.inbox.name}: webhook`, path: `/inbox/${a.inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
++ body: `<p><a href="/inbox/${esc(a.inbox.id)}/settings">&larr; ${esc(a.inbox.name)} settings</a></p><h1>Webhook added</h1>
++${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this once.')}<pre class="ib-snippet"><code>${esc(secret)}</code></pre>
++<p class="oc-muted">Check each delivery with it: <code>parseDelivery(rawBody, headers, secret, { requireV2: true })</code> from <code>openvibe-sdk/events</code> (X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2).</p>` });
++ } catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
++ });
++ router.post('/inbox/:id/webhooks/:wid/test', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a || a.role !== 'owner') return a && res.redirect(303, `/inbox/${a.inbox.id}/settings`);
++ try { const r = await webhooks.test(a.inbox, req.params.wid); res.redirect(303, `/inbox/${a.inbox.id}/settings?${r.ok ? 'saved=1' : `error=${encodeURIComponent(`The test got ${r.error}`)}`}`); }
++ catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
++ });
++ router.post('/inbox/:id/webhooks/:wid/delete', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ if (a.role === 'owner') await webhooks.remove(a.inbox, req.params.wid);
++ res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
++ });
++ router.post('/inbox/:id/replies', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ try { await inboxes.saveReply(a.inbox, a.actor.user, req.body || {}); res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`); }
++ catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }
++ });
++ router.post('/inbox/:id/replies/:rid/delete', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ try { await inboxes.deleteReply(a.inbox, a.actor.user, req.params.rid); } catch { /* unchanged */ }
++ res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
++ });
++ router.post('/inbox/:id/identity', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ try { await inboxes.setIdentity(a.inbox, a.actor.user, String((req.body && req.body.action) || '')); res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`); }
++ catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not work')}`); }
++ });
router.post('/inbox/:id/members', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
try { await inboxes.addMember(a.inbox, a.actor.user, req.body && req.body.username); res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`); }
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex 1a9c1ec..acc642d 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -27,7 +27,7 @@ async function until(fn, label) {
const visitorMessages = async () => (await widget('GET', `/messages?token=${token}`)).body.messages;
t('boot', async () => {
-- h = await boot({ env: { CHAT_WEB_URL: SITE } });
++ h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500' } });
aiClient = require('../server/ai/client');
inboxes = require('../server/inbox/inbox');
ann = h.addUser('ann', { subject: ids.newId('user') });
@@@ -191,6 +191,85 @@ t('a visitor waiting for a person tells the inbox\'s people, unless one follows
} finally { notify._setPush(null); }
});
++t('verified visitors: a signed identity names the visitor and follows them to another device; a wrong hash stays anonymous', async () => {
++ const crypto = require('crypto');
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/identity`, bob, { action: 'on' })).status, 403, 'the owner\'s call');
++ const secret = (await agentsApi('POST', `/${inbox.id}/identity`, ann, { action: 'on' })).body.identity_secret;
++ assert.ok(/^[0-9a-f]{64}$/.test(secret));
++ const user = { id: 'cust-42', name: 'Dana Lee', email: '[email protected]', hash: crypto.createHmac('sha256', secret).update('cust-42').digest('hex') };
++ const a = await widget('POST', '/visit', { user });
++ assert.strictEqual(a.body.verified, true);
++ const posted = await widget('POST', '/messages', { token: a.body.token, message: 'Order 42 is late', user });
++ assert.deepStrictEqual([posted.body.conversation.verified, posted.body.conversation.visitor_name, posted.body.conversation.visitor_email], [true, 'Dana Lee', '[email protected]']);
++ const b = await widget('POST', '/visit', { token: 'y'.repeat(32), user });
++ assert.strictEqual(b.body.token, a.body.token, 'another device: the same person, the same conversation');
++ assert.strictEqual(b.body.conversation.id, posted.body.conversation.id);
++ const forged = await widget('POST', '/visit', { user: { ...user, hash: '0'.repeat(64) } });
++ assert.ok(forged.body.verified === false && forged.body.token !== a.body.token, 'a wrong hash is just an anonymous visitor');
++ const viewPage = await fetch(`${h.base}/inbox/${inbox.id}?c=${posted.body.conversation.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((r) => r.text());
++ assert.ok(viewPage.includes('Verified by your site') && viewPage.includes('[email protected]'));
++ const settings = await fetch(`${h.base}/inbox/${inbox.id}/settings`, { headers: { cookie: `ov_token=${ann.token}` } }).then((r) => r.text());
++ assert.ok(settings.includes(secret) && settings.includes('hash_hmac'), 'the owner sees the secret and how to sign');
++ const agentSettings = await fetch(`${h.base}/inbox/${inbox.id}/settings`, { headers: { cookie: `ov_token=${bob.token}` } }).then((r) => r.text());
++ assert.ok(!agentSettings.includes(secret), 'an agent never sees it');
++ await agentsApi('POST', `/${inbox.id}/identity`, ann, { action: 'off' });
++ assert.strictEqual((await widget('POST', '/visit', { user })).body.verified, false, 'off: nobody is verified');
++});
++
++t('saved replies for the team (picked with "/"), and Vibe knows the page the visitor is on', async () => {
++ const r = await agentsApi('POST', `/${inbox.id}/replies`, bob, { shortcut: '/Refund', body: 'Refunds reach your card in 5 days.' });
++ assert.deepStrictEqual(r.body.replies.map((x) => [x.shortcut, x.body]), [['refund', 'Refunds reach your card in 5 days.']]);
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/replies`, bob, { shortcut: 'no spaces', body: 'x' })).status, 422);
++ const outsider = h.addUser('dee', { subject: ids.newId('user') });
++ assert.strictEqual((await agentsApi('GET', `/${inbox.id}/replies`, outsider)).status, 404);
++ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${conv.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
++ assert.ok(app.includes('/ for saved replies') && app.includes('Refunds reach your card in 5 days.'), 'the reply box knows them');
++ const id = r.body.replies[0].id;
++ assert.deepStrictEqual((await agentsApi('DELETE', `/${inbox.id}/replies/${id}`, ann)).body.replies, []);
++ reply = 'The Aurora ships in 2 days.';
++ turns.length = 0;
++ const v = (await widget('POST', '/visit', {})).body.token;
++ await widget('POST', '/messages', { token: v, message: 'When does this one ship?', page_url: 'https://shop.example/lamps/aurora' });
++ await until(async () => turns.some((x) => x.workflow === 'chat.member.turn'), 'Vibe asked');
++ assert.ok(turns.find((x) => x.workflow === 'chat.member.turn').input.messages[0].content.includes('The visitor is on this page: https://shop.example/lamps/aurora'));
++});
++
++t('webhooks: signed JSON for every event, a line for Slack when a visitor waits; private addresses refused; the owner\'s', async () => {
++ const webhooks = require('../server/inbox/webhooks');
++ const { parseDelivery } = require('openvibe-sdk/events');
++ const sent = [];
++ const lookup = (host, opts, cb) => cb(null, [{ address: host === 'internal.example' ? '10.1.2.3' : '93.184.216.34', family: 4 }]);
++ webhooks._setSend(async (url, body, headers) => { sent.push({ url, raw: body, body: JSON.parse(body), headers }); return { status: 200 }; }, lookup);
++ try {
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/webhooks`, bob, { url: 'https://example.com/hook' })).status, 403, 'the owner\'s call');
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/webhooks`, ann, { url: 'https://internal.example/hook' })).status, 422, 'a private address');
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/webhooks`, ann, { url: 'http://example.com/hook' })).status, 422, 'https only');
++ const json = await agentsApi('POST', `/${inbox.id}/webhooks`, ann, { url: 'https://example.com/hook' });
++ assert.ok(json.status === 201 && /^whsec_/.test(json.body.secret) && json.body.webhook.format === 'openvibe');
++ const slack = await agentsApi('POST', `/${inbox.id}/webhooks`, ann, { url: 'https://hooks.slack.com/services/T0/B0/xyz' });
++ assert.ok(slack.body.webhook.format === 'slack' && slack.body.secret === null, 'Slack\'s address is its own secret');
++ const v = (await widget('POST', '/visit', {})).body.token;
++ await widget('POST', '/messages', { token: v, message: 'The lamp will not turn on <!channel>' });
++ await widget('POST', '/handoff', { token: v });
++ await until(async () => sent.some((x) => x.url.includes('slack')), 'Slack hears the visitor waiting');
++ const types = sent.filter((x) => x.url === 'https://example.com/hook').map((x) => x.body.event.type);
++ assert.ok(['chat.inbox.conversation.created', 'chat.inbox.message.created', 'chat.inbox.conversation.waiting'].every((t2) => types.includes(t2)), types.join());
++ const one = sent.find((x) => x.body.event && x.body.event.type === 'chat.inbox.conversation.waiting');
++ const parsed = parseDelivery(one.raw, one.headers, json.body.secret, { requireV2: true });
++ assert.ok(parsed && parsed.event.type === 'chat.inbox.conversation.waiting', 'the signature checks out with openvibe-sdk/events');
++ assert.strictEqual(parseDelivery(one.raw, one.headers, 'whsec_wrong', { requireV2: true }), null);
++ assert.strictEqual(parsed.event.data.conversation.status, 'waiting');
++ const line = sent.find((x) => x.url.includes('slack')).body.text;
++ assert.ok(line.includes('is waiting for a person in Acme') && line.includes('The lamp will not turn on') && line.includes(`/inbox/${inbox.id}?c=`), line);
++ assert.ok(line.includes('&lt;!channel&gt;') && !line.includes('<!channel>'), 'a visitor cannot ping the channel');
++ const list = (await agentsApi('GET', `/${inbox.id}/webhooks`, ann)).body.webhooks;
++ assert.ok(list.every((w) => w.last_status === 200) && !JSON.stringify(list).includes('whsec_'), 'the list never shows the secret');
++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/webhooks/${json.body.webhook.id}/test`, ann)).body.ok, true);
++ assert.strictEqual((await agentsApi('DELETE', `/${inbox.id}/webhooks/${slack.body.webhook.id}`, ann)).status, 204);
++ await agentsApi('DELETE', `/${inbox.id}/webhooks/${json.body.webhook.id}`, ann);
++ } finally { webhooks._setSend(null); }
++});
++
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 5a2139f..9ae8d0f 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
const INDEXES = [
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 756f683..f34f60d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -120,6 +120,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
++ 'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
};
const root = path.join(__dirname, '..');
const found = [];