openvibers/openvibe.chat Commits
Support inboxes: records (finished conversations kept as long as the owner says, swept hourly; waiting and open ones never) and the owner's export (CSV, JSON) (#158)
Changes
@@@ -495,6 +495,13 @@ for people and for companies, with "direct live support with an agent", "fully m`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`("Try it" in the settings), `DELETE /:id/actions/:actionId`.++- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished++ conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:++ 7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet++ longer than that go, with their messages and translations; a visitor waiting for a person or a conversation a person++ has open never does. The owner downloads everything (the 5,000 newest conversations with their messages): `GET++ /api/chat/inboxes/:id/export?format=csv|json`, or the settings page's links. In the CSV, one row per message, a cell++ that would run as a spreadsheet formula starts with `'`.- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes toOpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the@@@ -65,5 +65,6 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,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,translation, business hours, routing (assigning, taking turns) and actions (Vibe calls the team's own endpoints,-- signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"). Next:-- retention and export settings, sharing the page with the visitor's permission (co-browsing-lite).++ signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"),++ retention (finished conversations kept 30 days to 2 years, or forever) and export (CSV, JSON). Next: sharing the page++ with the visitor's permission (co-browsing-lite).nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- How long an inbox keeps finished conversations (server/inbox/records.js): null keeps them; otherwise a resolved++-- conversation, or one only Vibe answered, quiet for longer than this many days goes with its messages.++ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS retention_days integer CHECK (retention_days IS NULL OR retention_days BETWEEN 7 AND 3650);@@@ -112,6 +112,7 @@ class ChatServer {/** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */this.soundboardStreamLimits = new Map();this._autoDeleteSweepInterval = null;++ this._inboxRetentionInterval = null;}normalizeIp(ip) {@@@ -234,6 +235,12 @@ class ChatServer {await this._sweepExpiredChatMessages();}, CHAT_AUTO_DELETE_SWEEP_MS);await this._sweepExpiredChatMessages();++ // Support inboxes keep finished conversations as long as their owners say (server/inbox/records.js): hourly.++ if (this._inboxRetentionInterval) clearInterval(this._inboxRetentionInterval);++ this._inboxRetentionInterval = setInterval(() => {++ require('../inbox/records').sweep().then((r) => { if (r.deleted) console.log(`[Inbox] retention: ${r.deleted} finished conversations removed`); }).catch((e) => console.warn('[Inbox] retention sweep:', e && e.message));++ }, 60 * 60 * 1000);++ if (this._inboxRetentionInterval.unref) this._inboxRetentionInterval.unref();this.wss.on('connection', (ws, req) => {this.handleConnection(ws, req);@@@ -2953,6 +2960,10 @@ class ChatServer {clearInterval(this._autoDeleteSweepInterval);this._autoDeleteSweepInterval = null;}++ if (this._inboxRetentionInterval) {++ clearInterval(this._inboxRetentionInterval);++ this._inboxRetentionInterval = null;++ }// The queue's work in flight (a clip being made or delivered) ends before the sockets and the database close.await audioQueue.stop();this.wss.clients.forEach(ws => ws.close());@@@ -72,6 +72,11 @@ function validate(input = {}, current = null) {if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);++ if (input.retention_days !== undefined) {++ const d = input.retention_days === null || input.retention_days === '' ? null : Number(input.retention_days);++ if (d !== null && !(Number.isInteger(d) && d >= 7 && d <= 3650)) fail(422, 'inbox.retention', 'Keep conversations for 7 to 3650 days, or null to keep them');++ out.retention_days = d;++ }if (input.route !== undefined) {if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');out.route = String(input.route);@@@ -130,8 +135,8 @@ async function update(inbox, user, input = {}) {const v = validate(input, inbox);const next = { ...inbox, ...v };const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));-- await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ? WHERE id = ?`,-- [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', inbox.id]);++ await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ?, retention_days = ? WHERE id = ?`,++ [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', next.retention_days == null ? null : Number(next.retention_days), inbox.id]);return await get(inbox.id);}@@@ -154,7 +159,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, hours: hoursOf(r), route: r.route || 'none',++ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none', retention_days: r.retention_days == null ? null : Number(r.retention_days),knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,};}nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * An inbox's records (migrations/0026_inbox_retention.sql): how long its finished conversations are kept, and the++ * owner's export of them.++ *++ * sweep({ now? }) → { deleted } hourly (server/chat/chat-server.js): in each inbox with retention_days, conversations++ * that are resolved, or that only Vibe ever answered, and quiet for longer than that,++ * go with their messages and translations, 500 an inbox a pass. A visitor waiting for a++ * person, or a conversation a person has open, is never swept.++ * conversations(inbox) → [{ ...convShape, messages }] newest first, at most 5,000 (the JSON export)++ * csv(inbox) → text the same, one row per message++ */++const db = require('../db/database');++++const RETENTION_CHOICES = [30, 90, 180, 365, 730]; // the settings' choices; the API takes 7 to 3650++const BATCH = 500;++const EXPORT_MAX = 5000;++++/** ov_now()'s format, for comparing with the conversations' text timestamps. */++const stamp = (d) => d.toISOString().replace('T', ' ').slice(0, 19);++++async function sweep({ now = new Date() } = {}) {++ let deleted = 0;++ const kept = await db.all('SELECT id, retention_days FROM inboxes WHERE retention_days IS NOT NULL');++ for (const ib of kept) {++ const cutoff = stamp(new Date(now.getTime() - Number(ib.retention_days) * 86_400_000));++ const ids = (await db.all(`SELECT id FROM inbox_conversations WHERE inbox_id = ? AND status IN ('resolved', 'bot') AND COALESCE(last_message_at, created_at) < ?++ ORDER BY COALESCE(last_message_at, created_at) LIMIT ${BATCH}`, [ib.id, cutoff])).map((r) => r.id);++ if (!ids.length) continue;++ await db.run('DELETE FROM inbox_translations WHERE message_id IN (SELECT id FROM inbox_messages WHERE conversation_id = ANY(?))', [ids]);++ await db.run('DELETE FROM inbox_messages WHERE conversation_id = ANY(?)', [ids]);++ await db.run('DELETE FROM inbox_conversations WHERE id = ANY(?)', [ids]);++ deleted += ids.length;++ }++ return { deleted };++}++++async function conversations(inbox) {++ const { convShape } = require('./inbox');++ const convs = await db.all(`SELECT * FROM inbox_conversations WHERE inbox_id = ? ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ${EXPORT_MAX}`, [inbox.id]);++ const byConv = new Map(convs.map((c) => [c.id, []]));++ if (convs.length) {++ for (const m of await db.all('SELECT id, conversation_id, author, author_name, body, created_at FROM inbox_messages WHERE conversation_id = ANY(?) ORDER BY id', [convs.map((c) => c.id)])) {++ byConv.get(m.conversation_id).push({ id: Number(m.id), author: m.author, author_name: m.author_name || null, body: m.body, created_at: m.created_at });++ }++ }++ return convs.map((c) => ({ ...convShape(c), created_at: c.created_at, last_message_at: c.last_message_at || null, messages: byConv.get(c.id) }));++}++++// A spreadsheet runs a cell that starts with = + - @ (or a tab or return before one) as a formula: such cells start with '.++const cell = (v) => { const s = v == null ? '' : String(v); return `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`; };++const COLUMNS = ['conversation_id', 'status', 'visitor_name', 'visitor_email', 'verified', 'page_url', 'rating', 'rating_comment', 'message_id', 'author', 'author_name', 'body', 'created_at'];++++async function csv(inbox) {++ const lines = [COLUMNS.join(',')];++ for (const c of await conversations(inbox)) {++ for (const m of c.messages) {++ lines.push([c.id, c.status, c.visitor_name, c.visitor_email, c.verified ? 'yes' : 'no', c.page_url, c.rating, c.rating_comment, m.id, m.author, m.author_name, m.body, m.created_at].map(cell).join(','));++ }++ }++ return `${lines.join('\r\n')}\r\n`;++}++++module.exports = { RETENTION_CHOICES, EXPORT_MAX, sweep, conversations, csv };@@@ -31,6 +31,7 @@* POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }* 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/export ?format=json|csv (owner): every conversation with its messages (5,000 newest), as a download* GET /:id/actions (owner) { actions } POST /:id/actions { name, description, url, params? } → 201 { action, secret }* POST /:id/actions/:actionId/test { arguments } → { ok, status, error, result } DELETE /:id/actions/:actionId* GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved@@@ -227,6 +228,22 @@ agents.delete('/:id/webhooks/:hookId', requireAuth, manageLimit, async (req, resconst inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;await webhooks.remove(inbox, req.params.hookId); res.status(204).end();});++agents.get('/:id/export', requireAuth, manageLimit, async (req, res) => {++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;++ await sendExport(res, inbox, req.query.format);++});++/** The inbox's conversations as a download (the API and the settings page's links). */++async function sendExport(res, inbox, format) {++ const records = require('./records');++ const name = `${inbox.name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'inbox'}-conversations`;++ res.set('Cache-Control', 'no-store');++ if (format === 'csv') {++ res.set('Content-Disposition', `attachment; filename="${name}.csv"`);++ return res.type('text/csv; charset=utf-8').send(await records.csv(inbox));++ }++ res.set('Content-Disposition', `attachment; filename="${name}.json"`);++ return res.json({ inbox: { id: inbox.id, name: inbox.name }, exported_at: new Date().toISOString(), conversations: await records.conversations(inbox) });++}agents.get('/:id/actions', requireAuth, async (req, res) => {const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;noStore(res).json({ actions: await actions.list(inbox) });@@@ -302,4 +319,4 @@ support.post('/:cid/messages', requireAuth, postLimit, async (req, res) => {try { res.status(201).json(await inboxes.accountPost(req.user, conv, req.body && req.body.message)); } catch (err) { send(res, err); }});--module.exports = { widget, agents, support, embedOf };++module.exports = { widget, agents, support, embedOf, sendExport };@@@ -18,7 +18,8 @@ const inboxes = require('../inbox/inbox');const webhooks = require('../inbox/webhooks');const actions = require('../inbox/actions');const pictures = require('../rooms/pictures');--const { embedOf } = require('../inbox/routes');++const { embedOf, sendExport } = require('../inbox/routes');++const records = require('../inbox/records');const { esc, asset } = require('./layout');const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');@@@ -241,6 +242,12 @@ ${acts.length < actions.MAX_ACTIONS ? `<form class="oc-form" method="post" actio<label>What it needs, one a line (name: what it is) <textarea name="params" maxlength="1500" rows="2" placeholder="order_id: the order number from the confirmation email"></textarea></label><label>Address (https) <input name="url" type="url" required maxlength="1000" placeholder="https://example.com/api/vibe/order"></label><p><button type="submit">Add the action</button></p></form>` : ''}++${owner ? `<h2>Records</h2>++<p class="oc-muted">How long finished conversations stay: resolved ones, and ones only Vibe answered, go once they have been quiet this long, with their messages. A visitor waiting for a person or a conversation one of your people has open never goes.</p>++<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/retention"><label>Keep finished conversations <select name="retention_days">++<option value=""${shape.retention_days == null ? ' selected' : ''}>forever</option>${records.RETENTION_CHOICES.map((d) => `<option value="${d}"${shape.retention_days === d ? ' selected' : ''}>${d >= 365 ? `${d / 365} year${d >= 730 ? 's' : ''}` : `${d} days`}</option>`).join('')}${shape.retention_days != null && !records.RETENTION_CHOICES.includes(shape.retention_days) ? `<option value="${shape.retention_days}" selected>${shape.retention_days} days</option>` : ''}++</select></label><p><button type="submit">Save</button></p></form>++<p>Download every conversation with its messages (the ${records.EXPORT_MAX.toLocaleString('en-US')} newest): <a href="/inbox/${esc(inbox.id)}/export.csv">a spreadsheet (CSV)</a> · <a href="/inbox/${esc(inbox.id)}/export.json">JSON</a></p>` : ''}<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>@@@ -332,6 +339,17 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this oif (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/retention', 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 { await inboxes.update(a.inbox, a.actor.user, { retention_days: (req.body && req.body.retention_days) || null }); 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.get('/inbox/:id/export.:format(csv|json)', async (req, res) => {++ const a = await agentInbox(req, res); if (!a) return;++ if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}`);++ await sendExport(res, a.inbox, req.params.format);++ });router.post('/inbox/:id/actions', 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`);@@@ -458,6 +458,54 @@ t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics asassert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');});++t('records: finished conversations go after the owner\'s retention, waiting and open ones never; the owner exports CSV and JSON', async () => {++ const records = require('../server/inbox/records');++ const fen = h.addUser('fen', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(fen.id));++ const ib = (await agentsApi('POST', '', fen, { name: 'Records Co', ai_enabled: false })).body.inbox;++ await agentsApi('POST', `/${ib.id}/members`, fen, { username: 'bob' });++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 3 })).status, 422, 'a week at least');++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, bob, { retention_days: 30 })).status, 403, 'the owner\'s call');++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 30 })).body.inbox.retention_days, 30);++ const say = (path, body) => fetch(`${h.base}/api/chat/widget/inbox/${ib.id}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json());++ const made = {};++ for (const [label, line] of [['resolved', '=HYPERLINK("http://evil.example","click")'], ['bot', 'only Vibe'], ['waiting', 'still waiting'], ['open', 'a person has it'], ['recent', 'resolved yesterday']]) {++ const token = (await say('/visit', {})).token;++ made[label] = (await say('/messages', { token, message: line })).conversation.id;++ }++ const old = '2026-01-01 00:00:00';++ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", [old, made.resolved]);++ await h.db.run("UPDATE inbox_conversations SET status = 'bot', last_message_at = ? WHERE id = ?", [old, made.bot]);++ await h.db.run("UPDATE inbox_conversations SET status = 'waiting', last_message_at = ? WHERE id = ?", [old, made.waiting]);++ await h.db.run("UPDATE inbox_conversations SET status = 'open', last_message_at = ? WHERE id = ?", [old, made.open]);++ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", ['2026-03-01 00:00:00', made.recent]);++++ // Exports first, while everything is there: the owner's, a spreadsheet cell can never run as a formula.++ assert.strictEqual((await agentsApi('GET', `/${ib.id}/export?format=csv`, bob)).status, 403, 'the owner\'s');++ const csvRes = await fetch(`${h.base}/api/chat/inboxes/${ib.id}/export?format=csv`, { headers: { authorization: `Bearer ${fen.token}` } });++ assert.ok(/text\/csv/.test(csvRes.headers.get('content-type')) && /attachment; filename="Records-Co-conversations\.csv"/.test(csvRes.headers.get('content-disposition')));++ const csv = await csvRes.text();++ assert.ok(csv.startsWith('conversation_id,status,visitor_name,visitor_email,verified,page_url,rating,rating_comment,message_id,author,author_name,body,created_at\r\n'));++ assert.ok(csv.includes(`"'=HYPERLINK(""http://evil.example"",""click"")"`) && !csv.includes('",=HYPERLINK'), 'a formula is quoted and defused');++ const json = (await agentsApi('GET', `/${ib.id}/export?format=json`, fen)).body;++ assert.strictEqual(json.inbox.id, ib.id);++ assert.strictEqual(json.conversations.length, 5);++ assert.ok(json.conversations.every((c) => Array.isArray(c.messages) && c.messages.length >= 1));++ const page = await fetch(`${h.base}/inbox/${ib.id}/export.json`, { headers: { cookie: `ov_token=${fen.token}` } });++ assert.ok(page.status === 200 && /attachment/.test(page.headers.get('content-disposition')), 'the settings page\'s link');++ const settings = await fetch(`${h.base}/inbox/${ib.id}/settings`, { headers: { cookie: `ov_token=${fen.token}` } }).then((r) => r.text());++ assert.ok(settings.includes('<h2>Records</h2>') && settings.includes('<option value="30" selected>30 days</option>') && settings.includes(`/inbox/${ib.id}/export.csv`));++++ // The sweep, on 2026-03-15 with 30 days: the old resolved and Vibe-only ones go, with their messages.++ const out = await records.sweep({ now: new Date('2026-03-15T12:00:00Z') });++ assert.ok(out.deleted >= 2);++ const left = (await h.db.all('SELECT id FROM inbox_conversations WHERE inbox_id = ?', [ib.id])).map((r) => r.id).sort();++ assert.deepStrictEqual(left, [made.waiting, made.open, made.recent].sort(), 'waiting and open stay, and the recent one');++ assert.strictEqual(Number((await h.db.get('SELECT COUNT(*) AS n FROM inbox_messages WHERE conversation_id = ANY(?)', [[made.resolved, made.bot]])).n), 0, 'their messages go too');++ await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: null });++ assert.strictEqual((await records.sweep({ now: new Date('2027-01-01T00:00:00Z') })).deleted, 0, 'forever: nothing goes');++});++t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');
@@ -495,6 +495,13 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -495,6 +495,13 @@ for people and for companies, with "direct live support with an agent", "fully m
`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
("Try it" in the settings), `DELETE /:id/actions/:actionId`.
("Try it" in the settings), `DELETE /:id/actions/:actionId`.
+- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished
+ conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:
+ 7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet
+ longer than that go, with their messages and translations; a visitor waiting for a person or a conversation a person
+ has open never does. The owner downloads everything (the 5,000 newest conversations with their messages): `GET
+ /api/chat/inboxes/:id/export?format=csv|json`, or the settings page's links. In the CSV, one row per message, a cell
+ that would run as a spreadsheet formula starts with `'`.
- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
@@ -65,5 +65,6 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
@@ -65,5 +65,6 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
5. **Enterprise**: built: verified visitors (an HMAC of the site's user id, like Intercom's identity verification),
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,
saved replies (the canned replies of other tools), ratings (CSAT), page context for Vibe, notifications,
translation, business hours, routing (assigning, taking turns) and actions (Vibe calls the team's own endpoints,
translation, business hours, routing (assigning, taking turns) and actions (Vibe calls the team's own endpoints,
- signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"). Next:
- retention and export settings, sharing the page with the visitor's permission (co-browsing-lite).
+ signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"),
+ retention (finished conversations kept 30 days to 2 years, or forever) and export (CSV, JSON). Next: sharing the page
+ with the visitor's permission (co-browsing-lite).
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- How long an inbox keeps finished conversations (server/inbox/records.js): null keeps them; otherwise a resolved
+-- conversation, or one only Vibe answered, quiet for longer than this many days goes with its messages.
+ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS retention_days integer CHECK (retention_days IS NULL OR retention_days BETWEEN 7 AND 3650);
@@ -112,6 +112,7 @@ class ChatServer {
@@ -112,6 +112,7 @@ class ChatServer {
/** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */
/** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */
this.soundboardStreamLimits = new Map();
this.soundboardStreamLimits = new Map();
this._autoDeleteSweepInterval = null;
this._autoDeleteSweepInterval = null;
+ this._inboxRetentionInterval = null;
}
}
normalizeIp(ip) {
normalizeIp(ip) {
@@ -234,6 +235,12 @@ class ChatServer {
@@ -234,6 +235,12 @@ class ChatServer {
await this._sweepExpiredChatMessages();
await this._sweepExpiredChatMessages();
}, CHAT_AUTO_DELETE_SWEEP_MS);
}, CHAT_AUTO_DELETE_SWEEP_MS);
await this._sweepExpiredChatMessages();
await this._sweepExpiredChatMessages();
+ // Support inboxes keep finished conversations as long as their owners say (server/inbox/records.js): hourly.
+ if (this._inboxRetentionInterval) clearInterval(this._inboxRetentionInterval);
+ this._inboxRetentionInterval = setInterval(() => {
+ require('../inbox/records').sweep().then((r) => { if (r.deleted) console.log(`[Inbox] retention: ${r.deleted} finished conversations removed`); }).catch((e) => console.warn('[Inbox] retention sweep:', e && e.message));
+ }, 60 * 60 * 1000);
+ if (this._inboxRetentionInterval.unref) this._inboxRetentionInterval.unref();
this.wss.on('connection', (ws, req) => {
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req);
this.handleConnection(ws, req);
@@ -2953,6 +2960,10 @@ class ChatServer {
@@ -2953,6 +2960,10 @@ class ChatServer {
clearInterval(this._autoDeleteSweepInterval);
clearInterval(this._autoDeleteSweepInterval);
this._autoDeleteSweepInterval = null;
this._autoDeleteSweepInterval = null;
}
}
+ if (this._inboxRetentionInterval) {
+ clearInterval(this._inboxRetentionInterval);
+ this._inboxRetentionInterval = null;
+ }
// The queue's work in flight (a clip being made or delivered) ends before the sockets and the database close.
// The queue's work in flight (a clip being made or delivered) ends before the sockets and the database close.
await audioQueue.stop();
await audioQueue.stop();
this.wss.clients.forEach(ws => ws.close());
this.wss.clients.forEach(ws => ws.close());
@@ -72,6 +72,11 @@ function validate(input = {}, current = null) {
@@ -72,6 +72,11 @@ function validate(input = {}, current = null) {
if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;
if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;
if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;
if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;
if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);
if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);
+ if (input.retention_days !== undefined) {
+ const d = input.retention_days === null || input.retention_days === '' ? null : Number(input.retention_days);
+ if (d !== null && !(Number.isInteger(d) && d >= 7 && d <= 3650)) fail(422, 'inbox.retention', 'Keep conversations for 7 to 3650 days, or null to keep them');
+ out.retention_days = d;
+ }
if (input.route !== undefined) {
if (input.route !== undefined) {
if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');
if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');
out.route = String(input.route);
out.route = String(input.route);
@@ -130,8 +135,8 @@ async function update(inbox, user, input = {}) {
@@ -130,8 +135,8 @@ async function update(inbox, user, input = {}) {
const v = validate(input, inbox);
const v = validate(input, inbox);
const next = { ...inbox, ...v };
const next = { ...inbox, ...v };
const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));
const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));
- await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ? WHERE id = ?`,
- [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', inbox.id]);
+ await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ?, retention_days = ? WHERE id = ?`,
+ [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', next.retention_days == null ? null : Number(next.retention_days), inbox.id]);
return await get(inbox.id);
return await get(inbox.id);
}
}
@@ -154,7 +159,7 @@ async function mine(user) {
@@ -154,7 +159,7 @@ async function mine(user) {
function shape(r) {
function shape(r) {
return {
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,
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, hours: hoursOf(r), route: r.route || 'none',
+ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none', retention_days: r.retention_days == null ? null : Number(r.retention_days),
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * An inbox's records (migrations/0026_inbox_retention.sql): how long its finished conversations are kept, and the
+ * owner's export of them.
+ *
+ * sweep({ now? }) → { deleted } hourly (server/chat/chat-server.js): in each inbox with retention_days, conversations
+ * that are resolved, or that only Vibe ever answered, and quiet for longer than that,
+ * go with their messages and translations, 500 an inbox a pass. A visitor waiting for a
+ * person, or a conversation a person has open, is never swept.
+ * conversations(inbox) → [{ ...convShape, messages }] newest first, at most 5,000 (the JSON export)
+ * csv(inbox) → text the same, one row per message
+ */
+const db = require('../db/database');
+
+const RETENTION_CHOICES = [30, 90, 180, 365, 730]; // the settings' choices; the API takes 7 to 3650
+const BATCH = 500;
+const EXPORT_MAX = 5000;
+
+/** ov_now()'s format, for comparing with the conversations' text timestamps. */
+const stamp = (d) => d.toISOString().replace('T', ' ').slice(0, 19);
+
+async function sweep({ now = new Date() } = {}) {
+ let deleted = 0;
+ const kept = await db.all('SELECT id, retention_days FROM inboxes WHERE retention_days IS NOT NULL');
+ for (const ib of kept) {
+ const cutoff = stamp(new Date(now.getTime() - Number(ib.retention_days) * 86_400_000));
+ const ids = (await db.all(`SELECT id FROM inbox_conversations WHERE inbox_id = ? AND status IN ('resolved', 'bot') AND COALESCE(last_message_at, created_at) < ?
+ ORDER BY COALESCE(last_message_at, created_at) LIMIT ${BATCH}`, [ib.id, cutoff])).map((r) => r.id);
+ if (!ids.length) continue;
+ await db.run('DELETE FROM inbox_translations WHERE message_id IN (SELECT id FROM inbox_messages WHERE conversation_id = ANY(?))', [ids]);
+ await db.run('DELETE FROM inbox_messages WHERE conversation_id = ANY(?)', [ids]);
+ await db.run('DELETE FROM inbox_conversations WHERE id = ANY(?)', [ids]);
+ deleted += ids.length;
+ }
+ return { deleted };
+}
+
+async function conversations(inbox) {
+ const { convShape } = require('./inbox');
+ const convs = await db.all(`SELECT * FROM inbox_conversations WHERE inbox_id = ? ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ${EXPORT_MAX}`, [inbox.id]);
+ const byConv = new Map(convs.map((c) => [c.id, []]));
+ if (convs.length) {
+ for (const m of await db.all('SELECT id, conversation_id, author, author_name, body, created_at FROM inbox_messages WHERE conversation_id = ANY(?) ORDER BY id', [convs.map((c) => c.id)])) {
+ byConv.get(m.conversation_id).push({ id: Number(m.id), author: m.author, author_name: m.author_name || null, body: m.body, created_at: m.created_at });
+ }
+ }
+ return convs.map((c) => ({ ...convShape(c), created_at: c.created_at, last_message_at: c.last_message_at || null, messages: byConv.get(c.id) }));
+}
+
+// A spreadsheet runs a cell that starts with = + - @ (or a tab or return before one) as a formula: such cells start with '.
+const cell = (v) => { const s = v == null ? '' : String(v); return `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`; };
+const COLUMNS = ['conversation_id', 'status', 'visitor_name', 'visitor_email', 'verified', 'page_url', 'rating', 'rating_comment', 'message_id', 'author', 'author_name', 'body', 'created_at'];
+
+async function csv(inbox) {
+ const lines = [COLUMNS.join(',')];
+ for (const c of await conversations(inbox)) {
+ for (const m of c.messages) {
+ lines.push([c.id, c.status, c.visitor_name, c.visitor_email, c.verified ? 'yes' : 'no', c.page_url, c.rating, c.rating_comment, m.id, m.author, m.author_name, m.body, m.created_at].map(cell).join(','));
+ }
+ }
+ return `${lines.join('\r\n')}\r\n`;
+}
+
+module.exports = { RETENTION_CHOICES, EXPORT_MAX, sweep, conversations, csv };
@@ -31,6 +31,7 @@
@@ -31,6 +31,7 @@
* POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
+ * GET /:id/export ?format=json|csv (owner): every conversation with its messages (5,000 newest), as a download
* GET /:id/actions (owner) { actions } POST /:id/actions { name, description, url, params? } → 201 { action, secret }
* GET /:id/actions (owner) { actions } POST /:id/actions { name, description, url, params? } → 201 { action, secret }
* POST /:id/actions/:actionId/test { arguments } → { ok, status, error, result } DELETE /:id/actions/:actionId
* POST /:id/actions/:actionId/test { arguments } → { ok, status, error, result } DELETE /:id/actions/:actionId
* GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved
* GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved
@@ -227,6 +228,22 @@ agents.delete('/:id/webhooks/:hookId', requireAuth, manageLimit, async (req, res
@@ -227,6 +228,22 @@ agents.delete('/:id/webhooks/:hookId', requireAuth, manageLimit, async (req, res
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
await webhooks.remove(inbox, req.params.hookId); res.status(204).end();
await webhooks.remove(inbox, req.params.hookId); res.status(204).end();
});
});
+agents.get('/:id/export', requireAuth, manageLimit, async (req, res) => {
+ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
+ await sendExport(res, inbox, req.query.format);
+});
+/** The inbox's conversations as a download (the API and the settings page's links). */
+async function sendExport(res, inbox, format) {
+ const records = require('./records');
+ const name = `${inbox.name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'inbox'}-conversations`;
+ res.set('Cache-Control', 'no-store');
+ if (format === 'csv') {
+ res.set('Content-Disposition', `attachment; filename="${name}.csv"`);
+ return res.type('text/csv; charset=utf-8').send(await records.csv(inbox));
+ }
+ res.set('Content-Disposition', `attachment; filename="${name}.json"`);
+ return res.json({ inbox: { id: inbox.id, name: inbox.name }, exported_at: new Date().toISOString(), conversations: await records.conversations(inbox) });
+}
agents.get('/:id/actions', requireAuth, async (req, res) => {
agents.get('/:id/actions', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
noStore(res).json({ actions: await actions.list(inbox) });
noStore(res).json({ actions: await actions.list(inbox) });
@@ -302,4 +319,4 @@ support.post('/:cid/messages', requireAuth, postLimit, async (req, res) => {
@@ -302,4 +319,4 @@ support.post('/:cid/messages', requireAuth, postLimit, async (req, res) => {
try { res.status(201).json(await inboxes.accountPost(req.user, conv, req.body && req.body.message)); } catch (err) { send(res, err); }
try { res.status(201).json(await inboxes.accountPost(req.user, conv, req.body && req.body.message)); } catch (err) { send(res, err); }
});
});
-module.exports = { widget, agents, support, embedOf };
+module.exports = { widget, agents, support, embedOf, sendExport };
@@ -18,7 +18,8 @@ const inboxes = require('../inbox/inbox');
@@ -18,7 +18,8 @@ const inboxes = require('../inbox/inbox');
const webhooks = require('../inbox/webhooks');
const webhooks = require('../inbox/webhooks');
const actions = require('../inbox/actions');
const actions = require('../inbox/actions');
const pictures = require('../rooms/pictures');
const pictures = require('../rooms/pictures');
-const { embedOf } = require('../inbox/routes');
+const { embedOf, sendExport } = require('../inbox/routes');
+const records = require('../inbox/records');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
@@ -241,6 +242,12 @@ ${acts.length < actions.MAX_ACTIONS ? `<form class="oc-form" method="post" actio
@@ -241,6 +242,12 @@ ${acts.length < actions.MAX_ACTIONS ? `<form class="oc-form" method="post" actio
<label>What it needs, one a line (name: what it is) <textarea name="params" maxlength="1500" rows="2" placeholder="order_id: the order number from the confirmation email"></textarea></label>
<label>What it needs, one a line (name: what it is) <textarea name="params" maxlength="1500" rows="2" placeholder="order_id: the order number from the confirmation email"></textarea></label>
<label>Address (https) <input name="url" type="url" required maxlength="1000" placeholder="https://example.com/api/vibe/order"></label>
<label>Address (https) <input name="url" type="url" required maxlength="1000" placeholder="https://example.com/api/vibe/order"></label>
<p><button type="submit">Add the action</button></p></form>` : ''}
<p><button type="submit">Add the action</button></p></form>` : ''}
+${owner ? `<h2>Records</h2>
+<p class="oc-muted">How long finished conversations stay: resolved ones, and ones only Vibe answered, go once they have been quiet this long, with their messages. A visitor waiting for a person or a conversation one of your people has open never goes.</p>
+<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/retention"><label>Keep finished conversations <select name="retention_days">
+<option value=""${shape.retention_days == null ? ' selected' : ''}>forever</option>${records.RETENTION_CHOICES.map((d) => `<option value="${d}"${shape.retention_days === d ? ' selected' : ''}>${d >= 365 ? `${d / 365} year${d >= 730 ? 's' : ''}` : `${d} days`}</option>`).join('')}${shape.retention_days != null && !records.RETENTION_CHOICES.includes(shape.retention_days) ? `<option value="${shape.retention_days}" selected>${shape.retention_days} days</option>` : ''}
+</select></label><p><button type="submit">Save</button></p></form>
+<p>Download every conversation with its messages (the ${records.EXPORT_MAX.toLocaleString('en-US')} newest): <a href="/inbox/${esc(inbox.id)}/export.csv">a spreadsheet (CSV)</a> · <a href="/inbox/${esc(inbox.id)}/export.json">JSON</a></p>` : ''}
<h2>How it looks and what Vibe knows</h2>
<h2>How it looks and what Vibe knows</h2>
<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/settings">
<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>Name <input name="name" required minlength="2" maxlength="60" value="${esc(shape.name)}"></label>
@@ -332,6 +339,17 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this o
@@ -332,6 +339,17 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this o
if (a.role === 'owner') await webhooks.remove(a.inbox, req.params.wid);
if (a.role === 'owner') await webhooks.remove(a.inbox, req.params.wid);
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
});
});
+ router.post('/inbox/:id/retention', 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 { await inboxes.update(a.inbox, a.actor.user, { retention_days: (req.body && req.body.retention_days) || null }); 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.get('/inbox/:id/export.:format(csv|json)', async (req, res) => {
+ const a = await agentInbox(req, res); if (!a) return;
+ if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}`);
+ await sendExport(res, a.inbox, req.params.format);
+ });
router.post('/inbox/:id/actions', formLimit, sameSite, async (req, res) => {
router.post('/inbox/:id/actions', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const a = await agentInbox(req, res); if (!a) return;
if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}/settings`);
if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}/settings`);
@@ -458,6 +458,54 @@ t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as
@@ -458,6 +458,54 @@ t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as
assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');
assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');
});
});
+t('records: finished conversations go after the owner\'s retention, waiting and open ones never; the owner exports CSV and JSON', async () => {
+ const records = require('../server/inbox/records');
+ const fen = h.addUser('fen', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(fen.id));
+ const ib = (await agentsApi('POST', '', fen, { name: 'Records Co', ai_enabled: false })).body.inbox;
+ await agentsApi('POST', `/${ib.id}/members`, fen, { username: 'bob' });
+ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 3 })).status, 422, 'a week at least');
+ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, bob, { retention_days: 30 })).status, 403, 'the owner\'s call');
+ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 30 })).body.inbox.retention_days, 30);
+ const say = (path, body) => fetch(`${h.base}/api/chat/widget/inbox/${ib.id}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json());
+ const made = {};
+ for (const [label, line] of [['resolved', '=HYPERLINK("http://evil.example","click")'], ['bot', 'only Vibe'], ['waiting', 'still waiting'], ['open', 'a person has it'], ['recent', 'resolved yesterday']]) {
+ const token = (await say('/visit', {})).token;
+ made[label] = (await say('/messages', { token, message: line })).conversation.id;
+ }
+ const old = '2026-01-01 00:00:00';
+ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", [old, made.resolved]);
+ await h.db.run("UPDATE inbox_conversations SET status = 'bot', last_message_at = ? WHERE id = ?", [old, made.bot]);
+ await h.db.run("UPDATE inbox_conversations SET status = 'waiting', last_message_at = ? WHERE id = ?", [old, made.waiting]);
+ await h.db.run("UPDATE inbox_conversations SET status = 'open', last_message_at = ? WHERE id = ?", [old, made.open]);
+ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", ['2026-03-01 00:00:00', made.recent]);
+
+ // Exports first, while everything is there: the owner's, a spreadsheet cell can never run as a formula.
+ assert.strictEqual((await agentsApi('GET', `/${ib.id}/export?format=csv`, bob)).status, 403, 'the owner\'s');
+ const csvRes = await fetch(`${h.base}/api/chat/inboxes/${ib.id}/export?format=csv`, { headers: { authorization: `Bearer ${fen.token}` } });
+ assert.ok(/text\/csv/.test(csvRes.headers.get('content-type')) && /attachment; filename="Records-Co-conversations\.csv"/.test(csvRes.headers.get('content-disposition')));
+ const csv = await csvRes.text();
+ assert.ok(csv.startsWith('conversation_id,status,visitor_name,visitor_email,verified,page_url,rating,rating_comment,message_id,author,author_name,body,created_at\r\n'));
+ assert.ok(csv.includes(`"'=HYPERLINK(""http://evil.example"",""click"")"`) && !csv.includes('",=HYPERLINK'), 'a formula is quoted and defused');
+ const json = (await agentsApi('GET', `/${ib.id}/export?format=json`, fen)).body;
+ assert.strictEqual(json.inbox.id, ib.id);
+ assert.strictEqual(json.conversations.length, 5);
+ assert.ok(json.conversations.every((c) => Array.isArray(c.messages) && c.messages.length >= 1));
+ const page = await fetch(`${h.base}/inbox/${ib.id}/export.json`, { headers: { cookie: `ov_token=${fen.token}` } });
+ assert.ok(page.status === 200 && /attachment/.test(page.headers.get('content-disposition')), 'the settings page\'s link');
+ const settings = await fetch(`${h.base}/inbox/${ib.id}/settings`, { headers: { cookie: `ov_token=${fen.token}` } }).then((r) => r.text());
+ assert.ok(settings.includes('<h2>Records</h2>') && settings.includes('<option value="30" selected>30 days</option>') && settings.includes(`/inbox/${ib.id}/export.csv`));
+
+ // The sweep, on 2026-03-15 with 30 days: the old resolved and Vibe-only ones go, with their messages.
+ const out = await records.sweep({ now: new Date('2026-03-15T12:00:00Z') });
+ assert.ok(out.deleted >= 2);
+ const left = (await h.db.all('SELECT id FROM inbox_conversations WHERE inbox_id = ?', [ib.id])).map((r) => r.id).sort();
+ assert.deepStrictEqual(left, [made.waiting, made.open, made.recent].sort(), 'waiting and open stay, and the recent one');
+ assert.strictEqual(Number((await h.db.get('SELECT COUNT(*) AS n FROM inbox_messages WHERE conversation_id = ANY(?)', [[made.resolved, made.bot]])).n), 0, 'their messages go too');
+ await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: null });
+ assert.strictEqual((await records.sweep({ now: new Date('2027-01-01T00:00:00Z') })).deleted, 0, 'forever: nothing goes');
+});
+
t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {
t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');