openvibers/openvibe.chat Commits
Support inboxes tell their people: a visitor waiting for a person sends each of them an OpenVibe notification when nobody has the inbox open (#145)
Changes
@@@ -449,6 +449,11 @@ for people and for companies, with "direct live support with an agent", "fully mreplies on Enter. The owner's settings page shows the tag, the greeting, colour, Vibe's instructions and pages, and**allowed sites**: once named, only those sites may frame the panel (`frame-ancestors`) or call the visitor API;until then any site may.++- **Nobody misses a visitor:** when a conversation starts waiting (the visitor asked for a person, Vibe handed off,++ or Vibe is off) or a visitor writes while it waits or is open, and none of the inbox's people has it open live, each++ of them (or only whoever took the conversation) gets an OpenVibe notification through the Network++ (`server/inbox/notify.js`; Chat's service token, `network.notifications.push` as service `chat`), at most one per++ conversation every 10 minutes except when it starts waiting. `CHAT_INBOX_NOTIFY=0` turns it off.- **Build your own panel** with the visitor API, which answers any site without cookies (CORS open, the token is thekey): `GET /api/chat/widget/inbox/:id` (name, greeting, colour, whether Vibe answers), `POST /visit { token? }` → `{token, conversation, messages }`, `GET /messages?after=` (token in `X-Visitor-Token`), `POST /messages { token,@@@ -2558,6 +2558,13 @@ class ChatServer {}}++ /** How many of an inbox's people follow it live right now (server/inbox/notify.js tells the others' devices). */++ inboxWatchers(inboxId) {++ let n = 0;++ for (const [ws, client] of this.clients) if (client.inboxAgent === inboxId && ws.readyState === WebSocket.OPEN) n++;++ return n;++ }++/** A support inbox's frame: to the visitor following that conversation and to the inbox's people following it. */broadcastInbox(inboxId, conversationId, data) {const msg = JSON.stringify(data);@@@ -72,7 +72,8 @@ module.exports = {// server-side with Chat's service token. The loopback address in production.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.-- inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)) },++ // ...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' },// 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') },@@@ -192,6 +192,7 @@ 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');return { ...conv, status };}@@@ -219,6 +220,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = nullif (!body) fail(422, 'inbox.empty', 'Write something first');if (wordFilter.isSpam && wordFilter.isSpam(body)) fail(422, 'inbox.spam', 'That looks like spam');let conv = await openConversationFor(inbox, token);++ const started = !conv;if (!conv) {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)@@@ -228,6 +230,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null}const filtered = wordFilter.check(body);const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);++ // A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.++ if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');if (conv.status === 'bot') {const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));if (deps.awaitAi) await run(); else setImmediate(run);nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * Telling an inbox's people (server/inbox/inbox.js) that a visitor needs one of them, when none of them follows the++ * inbox live (chat-server's inbox_watch): a conversation that starts waiting (the visitor asked for a person, Vibe++ * handed off, or Vibe is off), and a visitor writing while it waits or is open. One OpenVibe notification each through++ * the Network (Chat's service token, network.notifications.push as service 'chat'; a person can switch Chat++ * notifications off there), to whoever took the conversation, else everyone in the inbox. A visitor writing five lines++ * is one notification: at most one per conversation every 10 minutes, except that starting to wait always tells.++ * CHAT_INBOX_NOTIFY=0 turns it off.++ */++const db = require('../db/database');++const config = require('../config');++++const QUIET_MS = 10 * 60 * 1000;++const lastSent = new Map(); // conversation id → when its people were last told++let _push = null;++let _testPush = null;++++function push(notification) {++ if (!_push) {++ const { createClient } = require('openvibe-sdk');++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const { createNotificationsClient } = require('openvibe-sdk/notifications');++ const tokens = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'network.notifications.push identity.subject.resolve',++ });++ const client = createNotificationsClient(createClient({ tokenProvider: tokens, timeoutMs: 8000 }), { baseUrl: config.networkInternalUrl });++ _push = (n) => client.push(n);++ }++ return _push(notification);++}++++const watchedLive = (inboxId) => { try { return require('../chat/chat-server').inboxWatchers(inboxId) > 0; } catch { return false; } };++++/** reason: 'waiting' (it starts waiting for a person) | 'message' (the visitor wrote while it waits or is open). → people told */++async function people(conv, reason) {++ if (!config.inbox.notify || (!config.oauth.clientSecret && !_testPush)) return 0;++ const now = Date.now();++ if (reason !== 'waiting' && now - (lastSent.get(conv.id) || 0) < QUIET_MS) return 0;++ if (watchedLive(conv.inbox_id)) return 0;++ const inbox = await db.get('SELECT id, name FROM inboxes WHERE id = ? AND archived_at IS NULL', [conv.inbox_id]);++ if (!inbox) return 0;++ lastSent.set(conv.id, now);++ if (lastSent.size > 5000) lastSent.delete(lastSent.keys().next().value);++ const fresh = await db.get('SELECT assignee_id, visitor_name FROM inbox_conversations WHERE id = ?', [conv.id]);++ const ids = fresh && fresh.assignee_id ? [Number(fresh.assignee_id)]++ : (await db.all('SELECT user_id FROM inbox_members WHERE inbox_id = ? ORDER BY role DESC, added_at LIMIT 50', [inbox.id])).map((r) => Number(r.user_id));++ const last = await db.get("SELECT body FROM inbox_messages WHERE conversation_id = ? AND author = 'visitor' ORDER BY id DESC LIMIT 1", [conv.id]);++ const who = (fresh && fresh.visitor_name) || 'A visitor';++ const { plainText } = require('./inbox');++ const base = {++ type: 'SUPPORT_REQUEST', category: 'chat', service: 'chat', icon: '🛟', sender_name: who,++ priority: reason === 'waiting' ? 'high' : 'normal',++ title: reason === 'waiting' ? `${who} is waiting for a person · ${inbox.name}` : `${who} wrote · ${inbox.name}`,++ message: plainText(last && last.body).slice(0, 160) || 'Open the conversation to answer.',++ url: `${config.web.baseUrl}/inbox/${inbox.id}?c=${conv.id}`,++ };++ const ctx = require('../live-context');++ let told = 0;++ for (const id of ids) {++ try {++ const subjectId = await ctx.subjectFor(id);++ if (!subjectId) continue;++ const r = await (_testPush || push)({ subjectId, ...base });++ if (r && r.sent) told++;++ } catch (err) { console.warn(`[Inbox] notify ${inbox.id}: ${err && err.message}`); }++ }++ return told;++}++++/** Fire and forget: a notification never holds up the visitor or Vibe. */++function later(conv, reason) { people(conv, reason).catch((err) => console.warn(`[Inbox] notify: ${err && err.message}`)); }++++/** Tests: capture pushes instead of calling the Network (null restores it). */++function _setPush(fn) { _testPush = fn; lastSent.clear(); }++++module.exports = { people, later, QUIET_MS, _setPush };@@@ -129,6 +129,36 @@ t('the socket: the visitor hears the people, the people hear new conversations',agent.close(); visitor.close();});++t('a visitor waiting for a person tells the inbox\'s people, unless one follows it live; once per conversation for a while', async () => {++ const notify = require('../server/inbox/notify');++ const pushed = [];++ notify._setPush(async (n) => { pushed.push(n); return { sent: true }; });++ try {++ const v = (await widget('POST', '/visit', {})).body.token;++ await widget('POST', '/messages', { token: v, message: 'My lamp **flickers**' });++ await widget('POST', '/handoff', { token: v });++ await until(async () => pushed.length >= 2, 'the owner and the agent told');++ assert.deepStrictEqual(pushed.map((n) => n.subjectId).sort(), [ann.subject_id, bob.subject_id].sort());++ const n = pushed[0];++ assert.ok(n.title.includes('is waiting for a person') && n.title.includes('Acme'), n.title);++ assert.ok(n.url.startsWith(`${SITE}/inbox/${inbox.id}?c=cnv_`) && n.service === 'chat' && n.category === 'chat' && n.priority === 'high');++ assert.strictEqual(n.message, 'My lamp flickers', 'the visitor\'s words, as plain text');++ await widget('POST', '/messages', { token: v, message: 'Hello?' });++ await new Promise((r) => setTimeout(r, 150));++ assert.strictEqual(pushed.length, 2, 'a second line soon after is not another notification');++ // One of them follows the inbox live: nobody else is buzzed.++ const agent = await h.ws({ token: bob.token });++ agent.sendJson({ type: 'inbox_watch', inbox: inbox.id });++ await agent.next((m) => m.type === 'inbox_watching');++ const v2 = (await widget('POST', '/visit', {})).body.token;++ await widget('POST', '/messages', { token: v2, message: 'hi' });++ await widget('POST', '/handoff', { token: v2 });++ await new Promise((r) => setTimeout(r, 150));++ assert.strictEqual(pushed.length, 2);++ agent.close();++ } finally { notify._setPush(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);
@@ -449,6 +449,11 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -449,6 +449,11 @@ for people and for companies, with "direct live support with an agent", "fully m
replies on Enter. The owner's settings page shows the tag, the greeting, colour, Vibe's instructions and pages, and
replies on Enter. The owner's settings page shows the tag, the greeting, colour, Vibe's instructions and pages, and
**allowed sites**: once named, only those sites may frame the panel (`frame-ancestors`) or call the visitor API;
**allowed sites**: once named, only those sites may frame the panel (`frame-ancestors`) or call the visitor API;
until then any site may.
until then any site may.
+- **Nobody misses a visitor:** when a conversation starts waiting (the visitor asked for a person, Vibe handed off,
+ or Vibe is off) or a visitor writes while it waits or is open, and none of the inbox's people has it open live, each
+ of them (or only whoever took the conversation) gets an OpenVibe notification through the Network
+ (`server/inbox/notify.js`; Chat's service token, `network.notifications.push` as service `chat`), at most one per
+ conversation every 10 minutes except when it starts waiting. `CHAT_INBOX_NOTIFY=0` turns it off.
- **Build your own panel** with the visitor API, which answers any site without cookies (CORS open, the token is the
- **Build your own panel** with the visitor API, which answers any site without cookies (CORS open, the token is the
key): `GET /api/chat/widget/inbox/:id` (name, greeting, colour, whether Vibe answers), `POST /visit { token? }` → `{
key): `GET /api/chat/widget/inbox/:id` (name, greeting, colour, whether Vibe answers), `POST /visit { token? }` → `{
token, conversation, messages }`, `GET /messages?after=` (token in `X-Visitor-Token`), `POST /messages { token,
token, conversation, messages }`, `GET /messages?after=` (token in `X-Visitor-Token`), `POST /messages { token,
@@ -2558,6 +2558,13 @@ class ChatServer {
@@ -2558,6 +2558,13 @@ class ChatServer {
}
}
}
}
+ /** How many of an inbox's people follow it live right now (server/inbox/notify.js tells the others' devices). */
+ inboxWatchers(inboxId) {
+ let n = 0;
+ for (const [ws, client] of this.clients) if (client.inboxAgent === inboxId && ws.readyState === WebSocket.OPEN) n++;
+ return n;
+ }
+
/** A support inbox's frame: to the visitor following that conversation and to the inbox's people following it. */
/** A support inbox's frame: to the visitor following that conversation and to the inbox's people following it. */
broadcastInbox(inboxId, conversationId, data) {
broadcastInbox(inboxId, conversationId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
@@ -72,7 +72,8 @@ module.exports = {
@@ -72,7 +72,8 @@ module.exports = {
// server-side with Chat's service token. The loopback address in production.
// server-side with Chat's service token. The loopback address in production.
food: { internalUrl: strip(process.env.CHAT_FOOD_INTERNAL_URL || 'http://127.0.0.1:4970') },
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.
// Support inboxes (server/inbox/inbox.js): Vibe answers at most this many visitors' messages per inbox a UTC day.
- inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)) },
+ // ...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' },
// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
// 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') },
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
@@ -192,6 +192,7 @@ async function setStatusRow(conv, status, deps = {}, extra = {}) {
@@ -192,6 +192,7 @@ async function setStatusRow(conv, status, deps = {}, extra = {}) {
await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,
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]);
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 });
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');
return { ...conv, status };
return { ...conv, status };
}
}
@@ -219,6 +220,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
@@ -219,6 +220,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
if (!body) fail(422, 'inbox.empty', 'Write something first');
if (!body) fail(422, 'inbox.empty', 'Write something first');
if (wordFilter.isSpam && wordFilter.isSpam(body)) fail(422, 'inbox.spam', 'That looks like spam');
if (wordFilter.isSpam && wordFilter.isSpam(body)) fail(422, 'inbox.spam', 'That looks like spam');
let conv = await openConversationFor(inbox, token);
let conv = await openConversationFor(inbox, token);
+ const started = !conv;
if (!conv) {
if (!conv) {
const id = newId('cnv');
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)
await db.run(`INSERT INTO inbox_conversations (id, inbox_id, visitor_token_hash, visitor_name, status, page_url, last_message_at)
@@ -228,6 +230,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
@@ -228,6 +230,8 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
}
}
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);
const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);
+ // A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.
+ if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');
if (conv.status === 'bot') {
if (conv.status === 'bot') {
const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));
const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));
if (deps.awaitAi) await run(); else setImmediate(run);
if (deps.awaitAi) await run(); else setImmediate(run);
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Telling an inbox's people (server/inbox/inbox.js) that a visitor needs one of them, when none of them follows the
+ * inbox live (chat-server's inbox_watch): a conversation that starts waiting (the visitor asked for a person, Vibe
+ * handed off, or Vibe is off), and a visitor writing while it waits or is open. One OpenVibe notification each through
+ * the Network (Chat's service token, network.notifications.push as service 'chat'; a person can switch Chat
+ * notifications off there), to whoever took the conversation, else everyone in the inbox. A visitor writing five lines
+ * is one notification: at most one per conversation every 10 minutes, except that starting to wait always tells.
+ * CHAT_INBOX_NOTIFY=0 turns it off.
+ */
+const db = require('../db/database');
+const config = require('../config');
+
+const QUIET_MS = 10 * 60 * 1000;
+const lastSent = new Map(); // conversation id → when its people were last told
+let _push = null;
+let _testPush = null;
+
+function push(notification) {
+ if (!_push) {
+ const { createClient } = require('openvibe-sdk');
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const { createNotificationsClient } = require('openvibe-sdk/notifications');
+ const tokens = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'network.notifications.push identity.subject.resolve',
+ });
+ const client = createNotificationsClient(createClient({ tokenProvider: tokens, timeoutMs: 8000 }), { baseUrl: config.networkInternalUrl });
+ _push = (n) => client.push(n);
+ }
+ return _push(notification);
+}
+
+const watchedLive = (inboxId) => { try { return require('../chat/chat-server').inboxWatchers(inboxId) > 0; } catch { return false; } };
+
+/** reason: 'waiting' (it starts waiting for a person) | 'message' (the visitor wrote while it waits or is open). → people told */
+async function people(conv, reason) {
+ if (!config.inbox.notify || (!config.oauth.clientSecret && !_testPush)) return 0;
+ const now = Date.now();
+ if (reason !== 'waiting' && now - (lastSent.get(conv.id) || 0) < QUIET_MS) return 0;
+ if (watchedLive(conv.inbox_id)) return 0;
+ const inbox = await db.get('SELECT id, name FROM inboxes WHERE id = ? AND archived_at IS NULL', [conv.inbox_id]);
+ if (!inbox) return 0;
+ lastSent.set(conv.id, now);
+ if (lastSent.size > 5000) lastSent.delete(lastSent.keys().next().value);
+ const fresh = await db.get('SELECT assignee_id, visitor_name FROM inbox_conversations WHERE id = ?', [conv.id]);
+ const ids = fresh && fresh.assignee_id ? [Number(fresh.assignee_id)]
+ : (await db.all('SELECT user_id FROM inbox_members WHERE inbox_id = ? ORDER BY role DESC, added_at LIMIT 50', [inbox.id])).map((r) => Number(r.user_id));
+ const last = await db.get("SELECT body FROM inbox_messages WHERE conversation_id = ? AND author = 'visitor' ORDER BY id DESC LIMIT 1", [conv.id]);
+ const who = (fresh && fresh.visitor_name) || 'A visitor';
+ const { plainText } = require('./inbox');
+ const base = {
+ type: 'SUPPORT_REQUEST', category: 'chat', service: 'chat', icon: '🛟', sender_name: who,
+ priority: reason === 'waiting' ? 'high' : 'normal',
+ title: reason === 'waiting' ? `${who} is waiting for a person · ${inbox.name}` : `${who} wrote · ${inbox.name}`,
+ message: plainText(last && last.body).slice(0, 160) || 'Open the conversation to answer.',
+ url: `${config.web.baseUrl}/inbox/${inbox.id}?c=${conv.id}`,
+ };
+ const ctx = require('../live-context');
+ let told = 0;
+ for (const id of ids) {
+ try {
+ const subjectId = await ctx.subjectFor(id);
+ if (!subjectId) continue;
+ const r = await (_testPush || push)({ subjectId, ...base });
+ if (r && r.sent) told++;
+ } catch (err) { console.warn(`[Inbox] notify ${inbox.id}: ${err && err.message}`); }
+ }
+ return told;
+}
+
+/** Fire and forget: a notification never holds up the visitor or Vibe. */
+function later(conv, reason) { people(conv, reason).catch((err) => console.warn(`[Inbox] notify: ${err && err.message}`)); }
+
+/** Tests: capture pushes instead of calling the Network (null restores it). */
+function _setPush(fn) { _testPush = fn; lastSent.clear(); }
+
+module.exports = { people, later, QUIET_MS, _setPush };
@@ -129,6 +129,36 @@ t('the socket: the visitor hears the people, the people hear new conversations',
@@ -129,6 +129,36 @@ t('the socket: the visitor hears the people, the people hear new conversations',
agent.close(); visitor.close();
agent.close(); visitor.close();
});
});
+t('a visitor waiting for a person tells the inbox\'s people, unless one follows it live; once per conversation for a while', async () => {
+ const notify = require('../server/inbox/notify');
+ const pushed = [];
+ notify._setPush(async (n) => { pushed.push(n); return { sent: true }; });
+ try {
+ const v = (await widget('POST', '/visit', {})).body.token;
+ await widget('POST', '/messages', { token: v, message: 'My lamp **flickers**' });
+ await widget('POST', '/handoff', { token: v });
+ await until(async () => pushed.length >= 2, 'the owner and the agent told');
+ assert.deepStrictEqual(pushed.map((n) => n.subjectId).sort(), [ann.subject_id, bob.subject_id].sort());
+ const n = pushed[0];
+ assert.ok(n.title.includes('is waiting for a person') && n.title.includes('Acme'), n.title);
+ assert.ok(n.url.startsWith(`${SITE}/inbox/${inbox.id}?c=cnv_`) && n.service === 'chat' && n.category === 'chat' && n.priority === 'high');
+ assert.strictEqual(n.message, 'My lamp flickers', 'the visitor\'s words, as plain text');
+ await widget('POST', '/messages', { token: v, message: 'Hello?' });
+ await new Promise((r) => setTimeout(r, 150));
+ assert.strictEqual(pushed.length, 2, 'a second line soon after is not another notification');
+ // One of them follows the inbox live: nobody else is buzzed.
+ const agent = await h.ws({ token: bob.token });
+ agent.sendJson({ type: 'inbox_watch', inbox: inbox.id });
+ await agent.next((m) => m.type === 'inbox_watching');
+ const v2 = (await widget('POST', '/visit', {})).body.token;
+ await widget('POST', '/messages', { token: v2, message: 'hi' });
+ await widget('POST', '/handoff', { token: v2 });
+ await new Promise((r) => setTimeout(r, 150));
+ assert.strictEqual(pushed.length, 2);
+ agent.close();
+ } finally { notify._setPush(null); }
+});
+
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);