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)

OP OpenVibers committed 20 days ago · baaa1db107 · 6 files, +129 −1

Changes

ddiff --git a/README.md b/README.md
iindex a29ff09..857f282 100644
---- a/README.md
++++ b/README.md
@@@ -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
**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 the
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,
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex e1392c0..ba2013f 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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);
ddiff --git a/server/config.js b/server/config.js
iindex e14fbfe..c629e17 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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') },
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex 62604c3..e3e9302 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -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 = null
if (!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);
ddiff --git a/server/inbox/notify.js b/server/inbox/notify.js
nnew file mode 100644
iindex 0000000..87245ae
---- /dev/null
++++ b/server/inbox/notify.js
@@@ -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 };
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex 6226707..96f534b 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -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);