domma-cms 0.105.2 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{apiRequest as w}from"../api.js";import{fmtDate as b,fmtDateTime as y}from"./dates.js";const f=new Map;export function relatedProviders(e){return f.has(e)||f.set(e,w(`/related/providers?kind=${encodeURIComponent(e)}`,{method:"GET"}).then(t=>Array.isArray(t)?t:[]).catch(()=>[])),f.get(e)}export function relatedMenuItems(e,t,i=n=>n.label){return e.flatMap(n=>n.actions||[]).map(n=>({label:i(n),icon:n.icon||"arrow-right",action:()=>{location.hash=n.url.replace(/\{id\}/g,encodeURIComponent(t)).replace(/^#/,"")}}))}export function relatedWhen(e){if(e?.when)return String(e.when);if(!e?.startAt)return"";const t=new Date(e.startAt);return Number.isNaN(t.getTime())?"":e.allDay?b(t.toISOString().slice(0,10)):y(t)}const a=(e,t,i)=>{const n=document.createElement(e);return t&&(n.className=t),i!=null&&(n.textContent=i),n};export async function mountRelated(e,t,i,{onOpen:n,name:v=""}={}){e.hidden=!0;const h=await relatedProviders(t);if(!h.length||!e.isConnected)return;e.hidden=!1,e.replaceChildren(a("p","related-loading","Loading\u2026"));let g;try{g=await w(`/related?kind=${encodeURIComponent(t)}&id=${encodeURIComponent(i)}`,{method:"GET"})}catch{e.replaceChildren(a("p","related-note","Could not be loaded just now."));return}const I=(g?.providers||[]).map(r=>{const d=a("section","related-section"),s=a("div","related-head"),c=a("span");c.dataset.icon=r.icon||"link",c.dataset.iconSize="14",s.append(c,a("strong","",r.label));const p=(h.find(o=>o.id===r.id)?.actions||[])[0];if(p){const o=a("button","btn btn-sm btn-ghost related-add",p.label.replace(/\{name\}/g,v||"them"));o.type="button",o.addEventListener("click",()=>{n?.(),location.hash=p.url.replace(/\{id\}/g,encodeURIComponent(i)).replace(/^#/,"")}),s.append(o)}if(d.append(s),r.error)d.append(a("p","related-note",r.error));else if(!r.items?.length)d.append(a("p","related-note",`No ${r.label.toLowerCase()} yet.`));else{const o=a("ul","related-list");for(const l of r.items){const m=a("li",l.past?"is-past":""),u=a(l.url?"a":"span","related-title",l.title||"Untitled");l.url&&(u.href=l.url,u.addEventListener("click",()=>n?.())),m.append(u);const C=[relatedWhen(l),l.meta].filter(Boolean).join(" \xB7 ");C&&m.append(a("small","related-meta",C)),o.append(m)}d.append(o)}return d});e.replaceChildren(...I),window.I?.scan?.(e)}
1
+ import{apiRequest as f}from"../api.js";import{fmtDate as E,fmtDateTime as I}from"./dates.js";const b=new Map;export function relatedProviders(e){return b.has(e)||b.set(e,f(`/related/providers?kind=${encodeURIComponent(e)}`,{method:"GET"}).then(t=>Array.isArray(t)?t:[]).catch(()=>[])),b.get(e)}export function relatedMenuItems(e,t,a=n=>n.label){return e.flatMap(n=>n.actions||[]).map(n=>({label:a(n),icon:n.icon||"arrow-right",action:()=>{location.hash=n.url.replace(/\{id\}/g,encodeURIComponent(t)).replace(/^#/,"")}}))}export function relatedWhen(e){if(e?.when)return String(e.when);if(!e?.startAt)return"";const t=new Date(e.startAt);return Number.isNaN(t.getTime())?"":e.allDay?E(t.toISOString().slice(0,10)):I(t)}const r=(e,t,a)=>{const n=document.createElement(e);return t&&(n.className=t),a!=null&&(n.textContent=a),n};export function transcriptOf(e){const t=e?.transcript;if(!t||typeof t.url!="string"||!/^\/[\w\-/%.]+$/.test(t.url)||t.url.includes(".."))return null;const a=Math.max(0,Number(t.count)|0);return a?{url:t.url,count:a,title:String(t.title||"Call chat").slice(0,160)}:null}function R(e,t){const a=r("button","btn btn-sm btn-ghost related-chat",`${e.count} message${e.count===1?"":"s"}`);return a.type="button",a.title="Read the chat from this call",a.addEventListener("click",async()=>{a.disabled=!0;try{const[n,d]=await Promise.all([f(e.url,{method:"GET"}),import("/plugins/_lib/admin/ui/transcript.js")]);await d.openTranscript({title:e.title,subtitle:t?`Call started ${t}`:"",items:n?.items||[]})}catch(n){window.E?.toast?.(n?.status===404?"That chat is not available.":"The chat could not be loaded just now.",{type:"warning"})}finally{a.disabled=!1}}),a}export async function mountRelated(e,t,a,{onOpen:n,name:d=""}={}){e.hidden=!0;const g=await relatedProviders(t);if(!g.length||!e.isConnected)return;e.hidden=!1,e.replaceChildren(r("p","related-loading","Loading\u2026"));let w;try{w=await f(`/related?kind=${encodeURIComponent(t)}&id=${encodeURIComponent(a)}`,{method:"GET"})}catch{e.replaceChildren(r("p","related-note","Could not be loaded just now."));return}const v=(w?.providers||[]).map(l=>{const s=r("section","related-section"),p=r("div","related-head"),u=r("span");u.dataset.icon=l.icon||"link",u.dataset.iconSize="14",p.append(u,r("strong","",l.label));const m=(g.find(i=>i.id===l.id)?.actions||[])[0];if(m){const i=r("button","btn btn-sm btn-ghost related-add",m.label.replace(/\{name\}/g,d||"them"));i.type="button",i.addEventListener("click",()=>{n?.(),location.hash=m.url.replace(/\{id\}/g,encodeURIComponent(a)).replace(/^#/,"")}),p.append(i)}if(s.append(p),l.error)s.append(r("p","related-note",l.error));else if(!l.items?.length)s.append(r("p","related-note",`No ${l.label.toLowerCase()} yet.`));else{const i=r("ul","related-list");for(const o of l.items){const c=r("li",o.past?"is-past":""),h=r(o.url?"a":"span","related-title",o.title||"Untitled");o.url&&(h.href=o.url,h.addEventListener("click",()=>n?.())),c.append(h);const C=[relatedWhen(o),o.meta].filter(Boolean).join(" \xB7 ");C&&c.append(r("small","related-meta",C));const y=transcriptOf(o);y&&c.append(R(y,relatedWhen(o))),i.append(c)}s.append(i)}return s});e.replaceChildren(...v),window.I?.scan?.(e)}
@@ -37,6 +37,17 @@
37
37
  screen shows their link to copy or send. They need no account or app, and wait until you let
38
38
  them in. The link works for 3 hours. The contact's <strong>Calls</strong> list shows your
39
39
  recent calls with them.</p>
40
+ <p>What is typed in the call's chat is kept with the call. A call on the contact's
41
+ <strong>Calls</strong> list shows how many messages it has - click that to read them (only
42
+ you, as the one who made the call, or an admin). If you open the call again while it lasts,
43
+ the earlier messages are still there.</p>
44
+ <p><strong>Add people</strong> while the call is on: as the host, click <strong>Add people</strong>
45
+ in the call's control bar, search your contacts and the team, or type a name and email, and
46
+ click <strong>Invite</strong>. Each person gets a link of their own - by email, and team
47
+ members also as a notification and, when Messages is on, a message from you. They knock and
48
+ you let them in. <strong>Copy link</strong> gives you a link to pass on yourself. The same
49
+ button is in online bookings' calls (Booking Pro) and online meetings (Calendar); in a
50
+ Messages call it rings in another team member.</p>
40
51
  <p>Who may start a call is the <strong>Calls</strong> permission under <strong>System &gt;
41
52
  Roles</strong>; Admin is given it once, the first time a Tool with calls runs. A site without
42
53
  any of those Tools has no instant calls at all.</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.105.2",
3
+ "version": "0.107.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,154 @@
1
+ /**
2
+ * A call's chat, read-only, in a slideover - WhatsApp-style bubbles, day
3
+ * dividers, system lines ("Jo joined") centred. Shared by the CMS's instant
4
+ * calls (a contact's Calls list), Booking (a booking's panel) and Calendar (an
5
+ * online event's editor). Import it over HTTP: `/plugins/_lib/admin/ui/transcript.js`.
6
+ *
7
+ * Items are the call engine's (`_lib/call/CLAUDE.md` "Chat history"). The rows
8
+ * are built in JS (`transcriptRows`, pure) and every string reaches the page
9
+ * through a domma-reactive text binding - chat is never HTML. The stylesheet is
10
+ * this module's own (`--dm-*` tokens only), added to the page once.
11
+ *
12
+ * @module _lib/admin/ui/transcript
13
+ */
14
+ import {fmtDate} from './dates.js';
15
+
16
+ const GROUP_MS = 5 * 60000;
17
+ const HUES = 8;
18
+
19
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
20
+
21
+ /** "Today", "Yesterday" or dd/mm/yyyy - in the reader's zone. */
22
+ export function dayLabel(d, now = new Date()) {
23
+ if (dayKey(d) === dayKey(now)) return 'Today';
24
+ const y = new Date(now);
25
+ y.setDate(y.getDate() - 1);
26
+ if (dayKey(d) === dayKey(y)) return 'Yesterday';
27
+ return fmtDate(d);
28
+ }
29
+
30
+ /** A stable colour (1..8) for a sender. */
31
+ export function hueOf(s) {
32
+ let h = 0;
33
+ for (const c of String(s || '')) h = (h * 31 + c.charCodeAt(0)) >>> 0;
34
+ return String((h % HUES) + 1);
35
+ }
36
+
37
+ /**
38
+ * The rows for a transcript: one per item (oldest first), with its day divider,
39
+ * grouping (same sender within 5 minutes, no system line or new day between),
40
+ * side and time. PURE.
41
+ *
42
+ * @param {object[]} items - the engine's chat items
43
+ * @param {{mine?: (item: object) => boolean, now?: Date}} [opts]
44
+ * @returns {{id: string, cls: string, hue: string, day: string, sys: boolean, name: string, showName: boolean, text: string, time: string}[]}
45
+ */
46
+ export function transcriptRows(items, {mine = (i) => i?.mine === true, now = new Date()} = {}) {
47
+ const rows = [];
48
+ let last = null;
49
+ const seen = new Set();
50
+ for (const raw of Array.isArray(items) ? items : []) {
51
+ if (!raw || typeof raw.text !== 'string' || !raw.id || seen.has(raw.id)) continue;
52
+ seen.add(raw.id);
53
+ const at = new Date(raw.at || 0);
54
+ const when = Number.isNaN(at.getTime()) ? new Date(0) : at;
55
+ const sys = raw.kind === 'system';
56
+ const sender = String(raw.who || raw.from || raw.name || '');
57
+ const isMine = !sys && mine(raw) === true;
58
+ const day = !last || dayKey(last.when) !== dayKey(when) ? dayLabel(when, now) : '';
59
+ const first = sys || Boolean(day) || !last || last.sys || last.sender !== sender || when - last.when > GROUP_MS;
60
+ rows.push({
61
+ id: String(raw.id), sys, day, text: raw.text, name: String(raw.name || 'Someone'), showName: first && !sys && !isMine,
62
+ hue: hueOf(sender),
63
+ time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
64
+ cls: ['dmt-msg', sys ? 'is-sys' : isMine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()
65
+ });
66
+ last = {when, sys, sender};
67
+ }
68
+ return rows;
69
+ }
70
+
71
+ /** How many typed messages (not system lines). */
72
+ export const messageCount = (items) => (Array.isArray(items) ? items.filter(i => i?.kind === 'chat').length : 0);
73
+
74
+ /** "3 messages" / "1 message". */
75
+ export const messagesLabel = (n) => `${n} message${n === 1 ? '' : 's'}`;
76
+
77
+ const CSS = `
78
+ .dmt-panel { display: flex; flex-direction: column; gap: .5rem; min-height: 100%; }
79
+ .dmt-sub { margin: 0; color: var(--dm-text-muted); font-size: .85rem; }
80
+ .dmt-list { list-style: none; margin: 0; padding: .6rem .7rem 1rem; border-radius: .6rem; background: var(--dm-background-alt, var(--dm-surface)); border: 1px solid var(--dm-border); flex: 1; }
81
+ .dmt-empty { color: var(--dm-text-muted); font-size: .9rem; text-align: center; padding: 1.5rem .5rem; }
82
+ .dmt-msg { display: flex; flex-direction: column; margin-top: 2px; }
83
+ .dmt-msg.is-first { margin-top: .6rem; }
84
+ .dmt-msg.is-mine { align-items: flex-end; }
85
+ .dmt-msg.is-other { align-items: flex-start; }
86
+ .dmt-msg.is-sys { align-items: center; margin-top: .5rem; }
87
+ .dmt-day { align-self: center; margin: .5rem 0 .3rem; }
88
+ .dmt-day span, .dmt-sys span { display: inline-block; padding: .22rem .6rem; border-radius: .45rem; background: var(--dm-surface-raised, var(--dm-surface)); color: var(--dm-text-muted);
89
+ font-size: .75rem; border: 1px solid var(--dm-border); }
90
+ .dmt-day span { text-transform: uppercase; letter-spacing: .03em; font-size: .68rem; }
91
+ .dmt-bubble { position: relative; max-width: 85%; padding: .36rem .55rem .3rem .6rem; border-radius: .55rem; background: var(--dm-surface-raised, var(--dm-surface));
92
+ color: var(--dm-text); border: 1px solid var(--dm-border); white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.35; }
93
+ .is-mine .dmt-bubble { background: var(--dm-success-light, var(--dm-surface-raised)); }
94
+ .is-first.is-mine .dmt-bubble { border-top-right-radius: 0; }
95
+ .is-first.is-other .dmt-bubble { border-top-left-radius: 0; }
96
+ .dmt-from { display: block; font-size: .78rem; font-weight: 600; margin-bottom: .1rem; color: var(--dm-primary); }
97
+ .dmt-time { float: right; position: relative; top: .3rem; margin-left: .6rem; font-size: .68rem; line-height: 1; color: var(--dm-text-muted); font-variant-numeric: tabular-nums; }
98
+ .dmt-msg[data-hue="2"] .dmt-from, .dmt-msg[data-hue="7"] .dmt-from { color: var(--dm-success); }
99
+ .dmt-msg[data-hue="3"] .dmt-from, .dmt-msg[data-hue="8"] .dmt-from { color: var(--dm-info); }
100
+ .dmt-msg[data-hue="4"] .dmt-from { color: var(--dm-danger); }
101
+ .dmt-msg[data-hue="5"] .dmt-from { color: var(--dm-warning); }
102
+ .dmt-panel [hidden] { display: none !important; }
103
+ `;
104
+
105
+ function addStyles() {
106
+ if (document.getElementById('dm-transcript-css')) return;
107
+ const style = document.createElement('style');
108
+ style.id = 'dm-transcript-css';
109
+ style.textContent = CSS;
110
+ document.head.appendChild(style);
111
+ }
112
+
113
+ const ROW = `<li data-bind-class="cls" data-bind-data-hue="hue">
114
+ <div class="dmt-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
115
+ <div class="dmt-sys" data-bind-hidden="!sys"><span data-bind-text="text"></span></div>
116
+ <div class="dmt-bubble" data-bind-hidden="sys"><span class="dmt-from" data-bind-hidden="!showName" data-bind-text="name"></span><span class="dmt-text" data-bind-text="text"></span><span class="dmt-time" data-bind-text="time"></span></div>
117
+ </li>`;
118
+
119
+ /**
120
+ * Open the transcript in a right-hand slideover. `title` and `subtitle` are
121
+ * plain text. Returns the slideover (null when the admin has no Domma).
122
+ *
123
+ * @param {{title?: string, subtitle?: string, items: object[], mine?: (item: object) => boolean, empty?: string}} opts
124
+ */
125
+ export async function openTranscript({title = 'Call chat', subtitle = '', items = [], mine, empty = 'Nothing was typed in the chat.'} = {}) {
126
+ if (!window.E?.slideover || !window.M?.applyBindings) return null;
127
+ addStyles();
128
+ const rows = M.observable(transcriptRows(items, mine ? {mine} : {}));
129
+ const sub = M.observable(String(subtitle || ''));
130
+ const emptyText = M.observable(String(empty || ''));
131
+ // The title goes in as text: Domma's slideover title takes HTML.
132
+ const so = E.slideover({title: String(title).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`), size: 'md', position: 'right'});
133
+ const panel = document.createElement('div');
134
+ panel.className = 'dmt-panel';
135
+ panel.innerHTML = `<p class="dmt-sub" data-bind-hidden="!sub.value" data-bind-text="sub.value"></p>
136
+ <p class="dmt-empty" data-bind-hidden="rows.value.length" data-bind-text="emptyText.value"></p>
137
+ <div class="dmt-wrap" data-bind-hidden="!rows.value.length"><ol class="dmt-list" aria-label="Chat from the call" data-each="rows.value key=id"></ol></div>`;
138
+ // The row template goes in before binding (a templateUrl would eat {{...}}; this has none, but keep the order).
139
+ panel.querySelector('.dmt-list').innerHTML = ROW;
140
+ so.element.appendChild(panel);
141
+ const bindings = M.applyBindings({rows, sub, emptyText}, panel);
142
+ let onceClosed = null;
143
+ try { onceClosed = (await import('/admin/js/lib/tool-kit.js')).onceClosed; } catch { /* older admin */ }
144
+ const dispose = () => { try { bindings?.dispose?.(); } catch { /* gone */ } };
145
+ if (typeof onceClosed === 'function') onceClosed(so, dispose);
146
+ else {
147
+ const close = so.close.bind(so);
148
+ so.close = (...a) => { const r = close(...a); dispose(); setTimeout(() => { try { so.destroy?.(); } catch { /* gone */ } so.element?.remove(); }, 400); return r; };
149
+ }
150
+ so.open();
151
+ // Newest at the bottom, in view.
152
+ setTimeout(() => { const list = panel.querySelector('.dmt-list'); list?.lastElementChild?.scrollIntoView?.({block: 'end'}); }, 60);
153
+ return so;
154
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * A call chat store for a Tool: one JSON file holding every room's transcript,
3
+ * made to be handed to `mountCallRoutes(..., {chat: store})` (its `load` and
4
+ * `save`), plus what an admin screen needs to show a transcript afterwards.
5
+ *
6
+ * File shape: `{rooms: {<room>: {updatedAt, items: [...]}}}`. Growth is capped
7
+ * twice: the last `maxItems` items per room (the engine's HISTORY_MAX), and the
8
+ * `maxRooms` most recently used rooms (older transcripts go first). Writes are
9
+ * one after another (a promise chain) through `_lib/dataStore.js` (atomic
10
+ * .tmp + rename); the file is read once and then kept in memory - one process.
11
+ *
12
+ * @module _lib/call/chatStore
13
+ */
14
+
15
+ import {createStore} from '../dataStore.js';
16
+ import {HISTORY_MAX} from './rooms.js';
17
+
18
+ /** The fields an item keeps; anything else a caller passes is dropped. */
19
+ export function storedItem(raw) {
20
+ if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
21
+ const item = {id: String(raw.id).slice(0, 64), seq: Number(raw.seq) || 0, at: String(raw.at || ''),
22
+ kind: raw.kind === 'system' ? 'system' : 'chat', from: String(raw.from || '').slice(0, 64),
23
+ who: String(raw.who || '').slice(0, 64), name: String(raw.name || '').slice(0, 60), text: raw.text.slice(0, 1000)};
24
+ if (raw.event) item.event = String(raw.event).slice(0, 40);
25
+ return item;
26
+ }
27
+
28
+ /** How many typed messages (not system lines) a transcript holds. */
29
+ export const chatCount = (items) => (Array.isArray(items) ? items.filter(i => i?.kind === 'chat').length : 0);
30
+
31
+ /**
32
+ * @param {string} file - absolute path of the JSON file (a Tool's data/ folder)
33
+ * @param {{maxItems?: number, maxRooms?: number, now?: () => number}} [opts]
34
+ */
35
+ export function createChatStore(file, {maxItems = HISTORY_MAX, maxRooms = 1000, now = Date.now} = {}) {
36
+ const disk = createStore(file);
37
+ let data = null; // {rooms: {}} once read
38
+ let reading = null; // the ONE first read: two callers never each replace `data`
39
+ let chain = Promise.resolve();
40
+
41
+ function read() {
42
+ if (data) return Promise.resolve(data);
43
+ reading ??= Promise.resolve(disk.read()).then((raw) => {
44
+ data = {rooms: raw && typeof raw.rooms === 'object' && raw.rooms && !Array.isArray(raw.rooms) ? raw.rooms : {}};
45
+ return data;
46
+ }).finally(() => { reading = null; });
47
+ return reading;
48
+ }
49
+ /** Run `fn` after every earlier change; a failure is passed on to its caller, never to the next change. */
50
+ function queue(fn) {
51
+ const run = chain.then(fn);
52
+ chain = run.catch(() => {});
53
+ return run;
54
+ }
55
+ const key = (room) => String(room || '').slice(0, 160);
56
+
57
+ return {
58
+ /** The room's items, oldest first ([] when there are none). */
59
+ async load(room) {
60
+ await chain;
61
+ const r = (await read()).rooms[key(room)];
62
+ return Array.isArray(r?.items) ? r.items.map(i => ({...i})) : [];
63
+ },
64
+
65
+ /** Add one item (the engine's save). Kept: the last maxItems; the maxRooms most recently used rooms. */
66
+ save(room, item) {
67
+ const clean = storedItem(item);
68
+ if (!clean || !key(room)) return Promise.resolve();
69
+ return queue(async () => {
70
+ const d = await read();
71
+ const k = key(room);
72
+ const r = d.rooms[k] || (d.rooms[k] = {updatedAt: '', items: []});
73
+ if (!r.items.some(i => i.id === clean.id)) r.items.push(clean);
74
+ if (r.items.length > maxItems) r.items.splice(0, r.items.length - maxItems);
75
+ r.updatedAt = new Date(now()).toISOString();
76
+ const names = Object.keys(d.rooms);
77
+ if (names.length > maxRooms) {
78
+ names.sort((a, b) => String(d.rooms[a].updatedAt).localeCompare(String(d.rooms[b].updatedAt)))
79
+ .slice(0, names.length - maxRooms).forEach(n => { delete d.rooms[n]; });
80
+ }
81
+ await disk.write(d);
82
+ });
83
+ },
84
+
85
+ /** The rooms whose name starts with `prefix`: [{room, count, updatedAt}] (count = typed messages). */
86
+ async rooms(prefix = '') {
87
+ await chain;
88
+ const d = await read();
89
+ return Object.entries(d.rooms).filter(([k]) => k.startsWith(prefix))
90
+ .map(([k, r]) => ({room: k, count: chatCount(r.items), updatedAt: r.updatedAt}));
91
+ },
92
+
93
+ /** Forget a room's transcript. */
94
+ remove(room) {
95
+ return queue(async () => {
96
+ const d = await read();
97
+ if (!(key(room) in d.rooms)) return;
98
+ delete d.rooms[key(room)];
99
+ await disk.write(d);
100
+ });
101
+ }
102
+ };
103
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * "Add people" to a call that is under way - the pure parts every Tool shares.
3
+ *
4
+ * routes.js mounts the two routes (host only, see there) and calls the Tool's
5
+ * `invite.search(room, q, ctx)` / `invite.send(room, people, ctx)`. What goes out
6
+ * (the Join email, a notification, a Messages direct message) is core's:
7
+ * `server/services/callInvites.js`. This file holds what needs no service:
8
+ * the request's shape, the invite pass, the search's matching, and limits.
9
+ *
10
+ * The invite pass: an extra guest link for people added to a call. It never
11
+ * reuses the host's key or a guest's own token (those carry a name and an
12
+ * identity - someone invited with the customer's link would take the
13
+ * customer's place in the call). A pass is `<nonce>.<sig>`, the HMAC of
14
+ * `room|nonce` under a key the Tool keeps per call (`newInviteKey()`), so the
15
+ * key itself never leaves the server and each pass is its own identity: two
16
+ * invitees never replace each other, and one turned away cannot knock again
17
+ * with the same link. Every invite (and every "Copy link") gets a new pass.
18
+ *
19
+ * @module _lib/call/invite
20
+ */
21
+
22
+ import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
23
+
24
+ /** People in one invite request. */
25
+ export const MAX_TARGETS = 10;
26
+ /** People one caller may invite in a minute (a "Copy link" counts as one). */
27
+ export const INVITES_PER_MINUTE = 20;
28
+ /** Rows a search answers with. */
29
+ export const SEARCH_MAX = 12;
30
+ export const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
31
+ const ID_RE = /^[\w-]{1,64}$/;
32
+ const PASS_RE = /^([\w-]{8,24})\.([\w-]{16,64})$/;
33
+
34
+ /** A new per-call key for invite passes (kept by the Tool, never sent anywhere). */
35
+ export const newInviteKey = () => randomBytes(24).toString('base64url');
36
+
37
+ const sign = (key, room, nonce) => createHmac('sha256', String(key)).update(`${room}|${nonce}`).digest('base64url').slice(0, 32);
38
+
39
+ /** A new invite pass for `room` under the call's `key`. */
40
+ export function invitePass(key, room, nonce = randomBytes(9).toString('base64url')) {
41
+ if (!key) throw new Error('invitePass needs the call\'s invite key');
42
+ return `${nonce}.${sign(key, String(room), nonce)}`;
43
+ }
44
+
45
+ /** The pass's nonce when it was made for this room under this key, else null. */
46
+ export function checkInvitePass(key, room, pass) {
47
+ const m = PASS_RE.exec(String(pass || ''));
48
+ if (!key || !m) return null;
49
+ const want = Buffer.from(sign(key, String(room), m[1]));
50
+ const got = Buffer.from(m[2]);
51
+ return want.length === got.length && timingSafeEqual(want, got) ? m[1] : null;
52
+ }
53
+
54
+ /** "jo@example.com" -> "j•••@example.com": enough to tell two Jos apart, not enough to harvest. */
55
+ export function maskEmail(email) {
56
+ const s = String(email || '').trim();
57
+ const at = s.lastIndexOf('@');
58
+ if (at < 1) return '';
59
+ return `${s[0]}•••${s.slice(at)}`;
60
+ }
61
+
62
+ const bool = (v) => v === true;
63
+ const text = (v, n) => String(v ?? '').trim().replace(/\s+/g, ' ').slice(0, n);
64
+ /** One line of plain text, at most `n` characters (names in subjects, notices and messages). */
65
+ export const cleanText = (v, n = 120) => text(v, n);
66
+
67
+ /**
68
+ * The invite request, checked: `{people: [{kind: 'contact'|'user', id} | {kind: 'email', name, email}], via}` or
69
+ * `{error}`. Duplicates are dropped. An empty `people` is allowed (the caller wants a link to copy).
70
+ *
71
+ * @param {object} body - {people: [{kind, id} | {name, email}], via: {email, notify, message}}
72
+ */
73
+ export function cleanInviteRequest(body = {}) {
74
+ const raw = Array.isArray(body?.people) ? body.people : [];
75
+ if (raw.length > MAX_TARGETS) return {error: `Add at most ${MAX_TARGETS} people at a time.`};
76
+ const people = [];
77
+ const seen = new Set();
78
+ for (const p of raw) {
79
+ if (!p || typeof p !== 'object') return {error: 'Someone in the list is not a person.'};
80
+ if (p.kind === 'contact' || p.kind === 'user') {
81
+ const id = String(p.id ?? '');
82
+ if (!ID_RE.test(id)) return {error: 'Someone in the list is not a person.'};
83
+ const k = `${p.kind}:${id}`;
84
+ if (!seen.has(k)) { seen.add(k); people.push({kind: p.kind, id}); }
85
+ continue;
86
+ }
87
+ const email = text(p.email, 200).toLowerCase();
88
+ if (!EMAIL_RE.test(email)) return {error: `"${text(p.email, 60) || 'An email'}" does not look like an email address.`};
89
+ const name = text(p.name, 120) || email.split('@')[0];
90
+ const k = `email:${email}`;
91
+ if (!seen.has(k)) { seen.add(k); people.push({kind: 'email', name, email}); }
92
+ }
93
+ const via = body?.via && typeof body.via === 'object' ? body.via : {};
94
+ return {people, via: {email: bool(via.email), notify: bool(via.notify), message: bool(via.message)}};
95
+ }
96
+
97
+ /**
98
+ * Match a search against a host's contacts and the team. Contacts' emails are masked (the rows reach a browser
99
+ * holding only a call link) and matched only WHOLE - a part of an address would let someone rebuild a masked one
100
+ * letter by letter; names match on any part. Team members show no email at all.
101
+ *
102
+ * @param {{q?: string, contacts?: {id, name, email}[], users?: {id, name}[], excludeUserIds?: string[],
103
+ * canMessage?: boolean, limit?: number}} opts - `canMessage`: a direct message can reach team members
104
+ */
105
+ export function matchPeople({q = '', contacts = [], users = [], excludeUserIds = [], canMessage = false, limit = SEARCH_MAX} = {}) {
106
+ const want = text(q, 100).toLowerCase();
107
+ const hit = (...vals) => !want || vals.some(v => String(v || '').toLowerCase().includes(want));
108
+ const skip = new Set(excludeUserIds.map(String));
109
+ const team = users.filter(u => u && u.id && !skip.has(String(u.id)) && hit(u.name))
110
+ .map(u => ({kind: 'user', id: String(u.id), name: text(u.name, 120) || 'Team member', canNotify: true, canMessage: canMessage === true}));
111
+ // Contacts need something typed: an empty search offers the team only.
112
+ const known = want ? contacts.filter(c => c && c.id && c.email && (hit(c.name) || String(c.email).trim().toLowerCase() === want))
113
+ .map(c => ({kind: 'contact', id: String(c.id), name: text(c.name, 120) || c.email.split('@')[0], email: maskEmail(c.email),
114
+ canNotify: false, canMessage: false})) : [];
115
+ const byName = (a, b) => a.name.localeCompare(b.name);
116
+ return [...team.sort(byName), ...known.sort(byName)].slice(0, Math.max(1, limit));
117
+ }
118
+
119
+ /** A Tool's search rows, as the browser may have them. */
120
+ export function cleanPeople(list) {
121
+ return (Array.isArray(list) ? list : []).slice(0, SEARCH_MAX * 2).filter(p => p && (p.kind === 'contact' || p.kind === 'user') && ID_RE.test(String(p.id)))
122
+ .map(p => ({kind: p.kind, id: String(p.id), name: text(p.name, 120) || 'Someone', ...(p.email ? {email: text(p.email, 200)} : {}),
123
+ canNotify: p.canNotify === true, canMessage: p.canMessage === true}));
124
+ }
125
+
126
+ /** A Tool's send results, as the browser may have them. */
127
+ export function cleanResults(list) {
128
+ return (Array.isArray(list) ? list : []).slice(0, MAX_TARGETS).map(r => ({name: text(r?.name, 120) || 'Someone',
129
+ emailed: r?.emailed === true, notified: r?.notified === true, messaged: r?.messaged === true, ...(r?.error ? {error: text(r.error, 200)} : {})}));
130
+ }
131
+
132
+ /**
133
+ * Counts per key in a sliding window: `take(key, n)` is true (and counts n) while the key has room left.
134
+ *
135
+ * @param {{max?: number, windowMs?: number, now?: () => number}} [opts]
136
+ */
137
+ export function createLimiter({max = INVITES_PER_MINUTE, windowMs = 60000, now = Date.now} = {}) {
138
+ const seen = new Map(); // key -> [times]
139
+ return {
140
+ take(key, n = 1) {
141
+ const t = now();
142
+ const recent = (seen.get(key) || []).filter(x => x > t - windowMs);
143
+ if (recent.length + n > max) { seen.set(key, recent); return false; }
144
+ for (let i = 0; i < n; i++) recent.push(t);
145
+ seen.set(key, recent);
146
+ if (seen.size > 5000) for (const [k, l] of seen) if (!l.some(x => x > t - windowMs)) seen.delete(k);
147
+ return true;
148
+ },
149
+ reset: () => seen.clear()
150
+ };
151
+ }
152
+
153
+ /** The words of a notification / direct message: "Alex is calling you - Join". */
154
+ export const inviteTitle = (hostName) => `${text(hostName, 80) || 'Someone'} is calling you - Join`;
155
+ export const inviteMessage = (hostName, title, link) =>
156
+ `${text(hostName, 80) || 'Someone'} is inviting you to join a video call${title ? ` (${text(title, 120)})` : ''} now. Join here: ${link}`;
157
+
158
+ /**
159
+ * What "Add people" offers in a call, as the join tells the page (`inviteOptions`):
160
+ * search - search the team (and the host's contacts, when `typed`)
161
+ * typed - contacts and "type a name and email"
162
+ * copyLink - Copy link (a new pass, nothing sent)
163
+ * A Tool's resolve() gives `inviteOptions` (any of the three), or the older `inviteExternal: false` (team only).
164
+ * `external` stays for pages that read it: typed || copyLink.
165
+ */
166
+ export function inviteOptionsOf(r = {}) {
167
+ const given = r.inviteOptions && typeof r.inviteOptions === 'object' ? r.inviteOptions : {};
168
+ const team = r.inviteExternal === false;
169
+ const flag = (v, dflt) => (typeof v === 'boolean' ? v : dflt);
170
+ const o = {search: flag(given.search, true), typed: flag(given.typed, !team), copyLink: flag(given.copyLink, !team)};
171
+ return {...o, external: o.typed || o.copyLink};
172
+ }