domma-cms 0.105.2 → 0.106.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,10 @@
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>
40
44
  <p>Who may start a call is the <strong>Calls</strong> permission under <strong>System &gt;
41
45
  Roles</strong>; Admin is given it once, the first time a Tool with calls runs. A site without
42
46
  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.106.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
+ }
@@ -9,16 +9,23 @@
9
9
  * what is waiting for them. Every request after joining carries the
10
10
  * participant's id and secret.
11
11
  *
12
+ * Each room also keeps its chat history (the last HISTORY_MAX items): what
13
+ * people typed, plus system lines (joined, left, recording started/stopped),
14
+ * so someone arriving late sees what was said. It lives as long as the room;
15
+ * a Tool that wants it kept longer listens with `onItem` (routes.js does, for
16
+ * its `chat.save`) and seeds a new room from storage with `join(..., {history})`.
17
+ *
12
18
  * @module _lib/call/rooms
13
19
  */
14
20
 
15
- import {randomBytes, timingSafeEqual} from 'node:crypto';
21
+ import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
16
22
 
17
23
  export const MAX_PEOPLE = 6;
18
24
  const SILENT_MS = 35000; // no poll for this long: gone
19
25
  const POLL_MS = 25000; // a poll waits this long for something to happen
20
26
  const QUEUE_MAX = 500; // events kept per participant (a stuck browser cannot grow it forever)
21
- const CHAT_MAX = 1000;
27
+ const CHAT_MAX = 1000; // characters in one message
28
+ export const HISTORY_MAX = 500; // chat items a room keeps (and hands a late joiner)
22
29
  const EMPTY_KEEP_MS = 2 * 3600e3; // an empty room remembering who was turned away
23
30
 
24
31
  export class CallError extends Error {
@@ -28,6 +35,21 @@ export class CallError extends Error {
28
35
  }
29
36
  }
30
37
 
38
+ /**
39
+ * A stable, non-reversible tag for one person across reloads: the same link (identity) always gives the same
40
+ * `who`, so "mine" and a sender's colour survive a reload or a restart. No identity: the participant id.
41
+ */
42
+ export const whoOf = (identity, id) => (identity ? createHash('sha256').update(`dm-call:${identity}`).digest('base64url').slice(0, 16) : id);
43
+
44
+ /** A chat item as it is kept and handed out; anything else a store gave back is dropped. */
45
+ function cleanItem(raw) {
46
+ if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
47
+ const item = {id: String(raw.id).slice(0, 64), seq: Number(raw.seq) || 0, at: String(raw.at || ''), kind: raw.kind === 'system' ? 'system' : 'chat',
48
+ from: String(raw.from || ''), who: String(raw.who || raw.from || ''), name: String(raw.name || '').slice(0, 60), text: raw.text.slice(0, CHAT_MAX)};
49
+ if (raw.event) item.event = String(raw.event).slice(0, 40);
50
+ return item;
51
+ }
52
+
31
53
  const secretOk = (a, b) => {
32
54
  const x = Buffer.from(String(a || ''));
33
55
  const y = Buffer.from(String(b || ''));
@@ -35,11 +57,13 @@ const secretOk = (a, b) => {
35
57
  };
36
58
 
37
59
  /**
38
- * @param {{now?: () => number, pollMs?: number, silentMs?: number}} [opts]
60
+ * @param {{now?: () => number, pollMs?: number, silentMs?: number, onItem?: (key: string, item: object) => void}} [opts]
39
61
  */
40
- export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT_MS} = {}) {
41
- /** key -> {key, title, max, recordingAllowed, recording, people: Map<pid, person>} */
62
+ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT_MS, onItem} = {}) {
63
+ /** key -> {key, title, max, recordingAllowed, recording, people: Map<pid, person>, chat: item[], chatSeq} */
42
64
  const rooms = new Map();
65
+ /** Told about every new chat item (user and system) - never about history a room was seeded with. */
66
+ const listeners = new Set(onItem ? [onItem] : []);
43
67
 
44
68
  const newId = (n) => randomBytes(n).toString('base64url');
45
69
  const admitted = (room) => [...room.people.values()].filter(p => p.state === 'in');
@@ -55,6 +79,23 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
55
79
  }
56
80
  const tell = (people, event) => people.forEach(p => push(p, event));
57
81
 
82
+ /**
83
+ * Add one item to a room's history (capped) and hand it to the listeners. Returns the item. The listeners
84
+ * also get `{identity}` of the person it is about ('' for none) - for the server only, never in the item.
85
+ */
86
+ function record(room, fields, person) {
87
+ room.chatSeq += 1;
88
+ const item = {id: newId(9), seq: room.chatSeq, at: new Date(now()).toISOString(), kind: 'chat', from: '', who: '', name: '', text: '', ...fields};
89
+ room.chat.push(item);
90
+ if (room.chat.length > HISTORY_MAX) room.chat.splice(0, room.chat.length - HISTORY_MAX);
91
+ for (const fn of listeners) {
92
+ try { fn(room.key, {...item}, {identity: person?.identity || ''}); } catch { /* a listener never breaks a call */ }
93
+ }
94
+ return item;
95
+ }
96
+ const system = (room, person, event, text) => record(room, {kind: 'system', event, text,
97
+ from: person?.id || '', who: person?.who || '', name: person?.name || ''}, person);
98
+
58
99
  function me(key, pid, secret) {
59
100
  const room = rooms.get(key);
60
101
  const person = room?.people.get(String(pid || ''));
@@ -66,12 +107,15 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
66
107
  function remove(room, person, why) {
67
108
  if (!room.people.delete(person.id)) return;
68
109
  for (const w of person.waiters.splice(0)) w();
69
- if (person.state === 'in') tell(admitted(room), {type: 'left', id: person.id, name: person.name, why});
70
- else tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
110
+ if (person.state === 'in') {
111
+ // A reload is not news: no "left" line (and the join that follows adds no "joined" line either).
112
+ const item = why === 'rejoined' ? undefined : system(room, person, 'left', `${person.name} left`);
113
+ tell(admitted(room), {type: 'left', id: person.id, name: person.name, why, ...(item && {item})});
114
+ } else tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
71
115
  // The last host leaving stops a recording; nobody else can.
72
116
  if (room.recording && !hosts(room).length) {
73
117
  room.recording = false;
74
- tell(admitted(room), {type: 'recording', on: false});
118
+ tell(admitted(room), {type: 'recording', on: false, item: system(room, null, 'recording-off', 'Recording stopped')});
75
119
  }
76
120
  // An empty room goes - or, while it remembers who was turned away, two hours after it emptied (sweep).
77
121
  if (!room.people.size) {
@@ -82,11 +126,14 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
82
126
 
83
127
  const waitingList = (room) => [...room.people.values()].filter(p => p.state === 'waiting').map(p => ({id: p.id, name: p.name}));
84
128
 
85
- function admit(room, person) {
129
+ function admit(room, person, quiet = false) {
86
130
  if (admitted(room).length >= room.max) throw new CallError('full', `This call is full (${room.max} people).`);
87
131
  person.state = 'in';
88
- tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person)});
89
- push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording});
132
+ const item = quiet ? undefined : system(room, person, 'joined', `${person.name} joined`);
133
+ tell(admitted(room).filter(p => p !== person), {type: 'joined', peer: pub(person), ...(item && {item})});
134
+ // `chat` is the history so far (their own "joined" line included), so a late joiner sees what was said.
135
+ push(person, {type: 'admitted', peers: admitted(room).filter(p => p !== person).map(pub), recording: room.recording,
136
+ chat: room.chat.map(c => ({...c}))});
90
137
  tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
91
138
  }
92
139
 
@@ -96,12 +143,16 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
96
143
  * for a host unless the room lets everyone straight in.
97
144
  *
98
145
  * @param {string} key - the room, as the Tool names it ("booking:<id>")
99
- * @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean}} who
146
+ * @param {{name: string, host?: boolean, title?: string, max?: number, recordingAllowed?: boolean, autoAdmit?: boolean,
147
+ * identity?: string, history?: object[]}} who - `history` seeds the chat of a room this join CREATES (earlier
148
+ * items from storage); it is ignored when the room already exists, and never handed to the listeners.
100
149
  */
101
- join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = ''}) {
150
+ join(key, {name, host = false, title = '', max = MAX_PEOPLE, recordingAllowed = false, autoAdmit = false, identity = '', history}) {
102
151
  let room = rooms.get(key);
103
152
  if (!room) {
104
- room = {key, title, max: Math.max(2, Math.min(MAX_PEOPLE, max | 0 || MAX_PEOPLE)), recordingAllowed, recording: false, people: new Map(), away: new Set()};
153
+ const seeded = (Array.isArray(history) ? history : []).map(cleanItem).filter(Boolean).slice(-HISTORY_MAX);
154
+ room = {key, title, max: Math.max(2, Math.min(MAX_PEOPLE, max | 0 || MAX_PEOPLE)), recordingAllowed, recording: false, people: new Map(), away: new Set(),
155
+ chat: seeded, chatSeq: seeded.reduce((m, c) => Math.max(m, c.seq), 0)};
105
156
  rooms.set(key, room);
106
157
  }
107
158
  room.emptySince = 0;
@@ -109,21 +160,29 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
109
160
  if (!host && identity && room.away.has(identity)) throw new CallError('away', 'The host did not let you in to this call.');
110
161
  // The same link joining again (a reload, a second tab) replaces its old place at once, rather than
111
162
  // leaving a frozen copy of them in the call until the sweep notices.
112
- if (identity) for (const p of [...room.people.values()]) if (p.identity === identity) remove(room, p, 'rejoined');
163
+ let rejoin = false;
164
+ if (identity) {
165
+ for (const p of [...room.people.values()]) {
166
+ if (p.identity !== identity) continue;
167
+ rejoin = rejoin || p.state === 'in';
168
+ remove(room, p, 'rejoined');
169
+ }
170
+ }
113
171
  // remove() deletes a room it leaves empty - this one is about to have someone in it again.
114
172
  rooms.set(key, room);
115
173
  if (admitted(room).length >= room.max && (host || autoAdmit)) throw new CallError('full', `This call is full (${room.max} people).`);
116
- const person = {id: newId(9), secret: newId(24), identity: String(identity || ''), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
174
+ const id = newId(9);
175
+ const person = {id, secret: newId(24), identity: String(identity || ''), who: whoOf(identity, id), name: String(name || '').trim().slice(0, 60) || (host ? 'Host' : 'Guest'),
117
176
  host: Boolean(host), state: 'waiting', seq: 0, queue: [], waiters: [], seen: now()};
118
177
  room.people.set(person.id, person);
119
178
  if (host || autoAdmit) {
120
179
  // admit() also tells every host - this one included - who is already waiting.
121
- admit(room, person);
180
+ admit(room, person, rejoin);
122
181
  } else {
123
182
  push(person, {type: 'knocking'});
124
183
  tell(hosts(room), {type: 'waiting', waiting: waitingList(room)});
125
184
  }
126
- return {id: person.id, secret: person.secret, name: person.name, host: person.host, state: person.state, title: room.title,
185
+ return {id: person.id, secret: person.secret, who: person.who, name: person.name, host: person.host, state: person.state, title: room.title,
127
186
  max: room.max, recordingAllowed: room.recordingAllowed};
128
187
  },
129
188
 
@@ -166,8 +225,10 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
166
225
  if (person.state !== 'in') throw new CallError('waiting', 'Wait to be let in first.');
167
226
  const clean = String(text || '').trim().slice(0, CHAT_MAX);
168
227
  if (!clean) return false;
169
- tell(admitted(room), {type: 'chat', from: person.id, name: person.name, text: clean});
170
- return true;
228
+ const item = record(room, {kind: 'chat', from: person.id, who: person.who, name: person.name, text: clean}, person);
229
+ // from/name/text stay at the top level for older pages; `item` is the whole history entry.
230
+ tell(admitted(room), {type: 'chat', from: person.id, name: person.name, text: clean, item});
231
+ return item;
171
232
  },
172
233
 
173
234
  /** A host lets someone in (yes) or turns them away (no). */
@@ -192,8 +253,11 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
192
253
  const {room, person} = me(key, pid, secret);
193
254
  if (!person.host || person.state !== 'in') throw new CallError('not-host', 'Only the host can record.');
194
255
  if (!room.recordingAllowed && on) throw new CallError('no-recording', 'Recording is switched off for this call.');
256
+ const was = room.recording;
195
257
  room.recording = Boolean(on);
196
- tell(admitted(room), {type: 'recording', on: room.recording, by: person.name});
258
+ const item = was === room.recording ? undefined
259
+ : system(room, person, room.recording ? 'recording-on' : 'recording-off', `${person.name} ${room.recording ? 'started' : 'stopped'} recording`);
260
+ tell(admitted(room), {type: 'recording', on: room.recording, by: person.name, ...(item && {item})});
197
261
  return room.recording;
198
262
  },
199
263
 
@@ -212,6 +276,25 @@ export function createRooms({now = Date.now, pollMs = POLL_MS, silentMs = SILENT
212
276
  }
213
277
  },
214
278
 
279
+ /** Is there a room under this key right now (someone in it, waiting, or remembered)? */
280
+ has: (key) => rooms.has(key),
281
+
282
+ /** A copy of a room's chat history, oldest first ([] when there is no room). */
283
+ history(key) {
284
+ const room = rooms.get(key);
285
+ return room ? room.chat.map(c => ({...c})) : [];
286
+ },
287
+
288
+ /**
289
+ * Hear about every new chat item: `fn(key, item, {identity})`, for user messages and system lines alike -
290
+ * `identity` is the join's identity of the person concerned ('' when none), for server-side use only (it
291
+ * is never put in an item). Returns a function that stops listening. A listener that throws is ignored.
292
+ */
293
+ onItem(fn) {
294
+ listeners.add(fn);
295
+ return () => listeners.delete(fn);
296
+ },
297
+
215
298
  /** For the admin: how many are in a room and waiting (0 when there is no call). */
216
299
  count(key) {
217
300
  const room = rooms.get(key);
@@ -15,6 +15,10 @@
15
15
  * After joining, every request carries `X-Call-Id` and `X-Call-Secret`
16
16
  * (headers, so the secret never lands in an access log).
17
17
  *
18
+ * Chat history: each room keeps its own (see rooms.js). A Tool that passes
19
+ * `chat: {load, save}` makes it outlive the room - `load(room)` seeds a room
20
+ * as it is created, `save(room, item)` gets every new item. See CLAUDE.md.
21
+ *
18
22
  * @module _lib/call/routes
19
23
  */
20
24
 
@@ -34,11 +38,45 @@ const BUSY = {config: {rateLimit: {max: 1200, timeWindow: '1 minute'}}};
34
38
  * who the caller is in this room, or null when they may not join. Throw nothing: null answers 404.
35
39
  * @param {ReturnType<typeof createRooms>} [opts.rooms] - for tests
36
40
  * @param {() => {stun: string[], turn: object|null}} [opts.config]
41
+ * @param {{load?: (room: string) => Promise<object[]>, save?: (room: string, item: object, meta: {identity: string}) => Promise<void>}} [opts.chat]
42
+ * optional storage for the chat: `load(room)` gives the earlier items of a room being created (oldest first),
43
+ * `save(room, item, {identity})` is handed every new item, user and system. `room` is the Tool's own room id (no
44
+ * namespace); `identity` is what the Tool's resolve() gave the person concerned ('' for none) - server side only.
45
+ * Failures (and a load slower than 5 s) are logged and otherwise ignored: a chat never breaks a call.
37
46
  * @returns {ReturnType<typeof createRooms>} the rooms, so the Tool can close() one or count() it
38
47
  */
39
- export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig()}) {
48
+ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms = createRooms(), config = () => readCallConfig(), chat = null}) {
40
49
  if (!ns) throw new Error('mountCallRoutes needs a namespace');
41
50
  const keyOf = (room) => `${ns}:${String(room || '').slice(0, 120)}`;
51
+ const roomOf = (key) => key.slice(ns.length + 1);
52
+ const log = fastify.log || console;
53
+
54
+ // ---- Chat storage (optional) --------------------------------------------------------
55
+ // Saves for one room run one after another, so the store sees items in order.
56
+ const saving = new Map(); // key -> promise of the last save
57
+ const save = (key, item, meta) => {
58
+ const next = (saving.get(key) || Promise.resolve())
59
+ .then(() => chat.save(roomOf(key), item, {identity: String(meta?.identity || '')}))
60
+ .catch(err => log.warn?.({err: err?.message || String(err), room: roomOf(key)}, '[call] chat save failed'));
61
+ saving.set(key, next);
62
+ next.then(() => { if (saving.get(key) === next) saving.delete(key); });
63
+ return next;
64
+ };
65
+ const stopListening = typeof chat?.save === 'function'
66
+ ? rooms.onItem((key, item, meta) => { if (key.startsWith(`${ns}:`)) save(key, item, meta); })
67
+ : null;
68
+ const load = async (room) => {
69
+ if (typeof chat?.load !== 'function') return undefined;
70
+ let timer;
71
+ try {
72
+ const items = await Promise.race([chat.load(room),
73
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error('timed out after 5 s')), 5000); })]);
74
+ return Array.isArray(items) ? items : [];
75
+ } catch (err) {
76
+ log.warn?.({err: err?.message || String(err), room}, '[call] chat load failed');
77
+ return [];
78
+ } finally { clearTimeout(timer); }
79
+ };
42
80
  const who = (request) => [request.headers['x-call-id'], request.headers['x-call-secret']];
43
81
  const fail = (reply, err) => {
44
82
  if (err instanceof CallError) return reply.code(STATUS[err.code] || 400).send({error: err.message, code: err.code});
@@ -47,7 +85,7 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
47
85
 
48
86
  const sweeper = setInterval(() => rooms.sweep(), 5000);
49
87
  sweeper.unref?.();
50
- fastify.addHook('onClose', async () => clearInterval(sweeper));
88
+ fastify.addHook('onClose', async () => { clearInterval(sweeper); stopListening?.(); });
51
89
 
52
90
  fastify.post(`${prefix}/:room/join`, {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
53
91
  const body = request.body || {};
@@ -56,9 +94,12 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
56
94
  const t = Date.now();
57
95
  if (r.opensAt && t < Date.parse(r.opensAt)) return reply.code(425).send({error: 'This call has not opened yet.', code: 'early', opensAt: r.opensAt});
58
96
  if (r.closesAt && t > Date.parse(r.closesAt)) return reply.code(410).send({error: 'This call has ended.', code: 'closed'});
97
+ const key = keyOf(request.params.room);
98
+ // A room being created starts from its stored chat (a room reopened later, or after a restart).
99
+ const history = rooms.has(key) ? undefined : await load(roomOf(key));
59
100
  try {
60
- const joined = rooms.join(keyOf(request.params.room), {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
61
- recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || ''});
101
+ const joined = rooms.join(key, {name: r.name || body.name, host: r.host, title: r.title, max: r.max,
102
+ recordingAllowed: r.recordingAllowed === true, autoAdmit: r.autoAdmit === true, identity: r.identity || '', history});
62
103
  return reply.send({...joined, iceServers: iceServers(config(), request.hostname), closesAt: r.closesAt || null});
63
104
  } catch (err) { return fail(reply, err); }
64
105
  });
@@ -79,11 +120,20 @@ export function mountCallRoutes(fastify, {prefix = '/call', ns, resolve, rooms =
79
120
  } catch (err) { return fail(reply, err); }
80
121
  });
81
122
  action('signal', (k, id, s, b) => rooms.signal(k, id, s, b.to, b.data));
82
- action('chat', (k, id, s, b) => rooms.chat(k, id, s, b.text));
123
+ fastify.post(`${prefix}/:room/chat`, BUSY, async (request, reply) => {
124
+ const [id, secret] = who(request);
125
+ const key = keyOf(request.params.room);
126
+ let item;
127
+ try { item = rooms.chat(key, id, secret, (request.body || {}).text); } catch (err) { return fail(reply, err); }
128
+ // Answered once the store has had it (a failed save is only logged).
129
+ if (item && saving.has(key)) await saving.get(key);
130
+ return reply.send({ok: true, result: item || false});
131
+ });
83
132
  action('answer', (k, id, s, b) => rooms.answer(k, id, s, b.who, b.yes === true));
84
133
  action('recording', (k, id, s, b) => rooms.recording(k, id, s, b.on === true));
85
134
  action('leave', (k, id, s) => rooms.leave(k, id, s));
86
135
 
87
136
  return {...rooms, closeRoom: (room) => rooms.close(keyOf(room)), countRoom: (room) => rooms.count(keyOf(room)),
137
+ historyOfRoom: (room) => rooms.history(keyOf(room)),
88
138
  kickFromRoom: (room, identity) => rooms.kick(keyOf(room), identity)};
89
139
  }