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,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@25153e0",
3
+ "source": "dcms-marketplace@f582d78",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -73,19 +73,19 @@
73
73
  }
74
74
  },
75
75
  "booking": {
76
- "version": "1.4.1",
76
+ "version": "1.6.0",
77
77
  "files": {
78
- "CLAUDE.md": "c4401810011ed98fe488e89e03d32ee601025818d1f354ec07fcd9f9add1115a",
78
+ "CLAUDE.md": "9bcab41375a2aaecbdd332766ceca5b18bf9dafa99d4c8315549e38033c1f5c5",
79
79
  "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
80
  "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
81
  "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
82
82
  "admin/lib/extension-sections.js": "0588ad47660e511b0dd8077bb9c3999c775d97597333a75e87ee12d915bbf00b",
83
- "admin/lib/kit.js": "661e9c407bf398694dfa08105590065a6352594d526f3c09c31b180c840045bf",
83
+ "admin/lib/kit.js": "b69b02172d76ef80879da3f003cc1e687c6149626c9e58af51729c2e4a3ff959",
84
84
  "admin/templates/booking.html": "20b9a3bfe3e5d5bbee613f29be99551b12790fb38f80d119585360cc38e9ec57",
85
85
  "admin/views/booking.js": "4eddf6c0b60dbbba4c6e35352df247a3d34b66ee1d43c51fe2d53f894707348f",
86
86
  "admin/views/bookings.js": "bf17db82ed4d2f5c29ac924dd60dc0b84c8bd9c8ce32da6cbb011527699a56a6",
87
87
  "admin/views/diary.js": "9e8976e67c2232d353b3d9a1ef0d0503cc4d06b710d54d0cba84fb5b796fdfe9",
88
- "admin/views/panels.js": "9d1c45e56d2a7a8caa12e9af29545c808ffb88da3ea48397d55aae19e802ec69",
88
+ "admin/views/panels.js": "1d269d90553a5482fd6ebd18180cb736011770f0204d5c1306948abcb4b676b2",
89
89
  "admin/views/people.js": "58cb9cc550b48c959a5572a4d25650ea2b32b658c9d0bf4c244e4abe0277c211",
90
90
  "admin/views/services.js": "122326d222ed16dc8ec903fef6f62f6a7638c31d73c4612b7a8c2ced38358068",
91
91
  "admin/views/settings.js": "299297b47a6509986276a859807a33ea225681d28e0e9cd06cdf36b0f6ec0c11",
@@ -93,15 +93,15 @@
93
93
  "collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
94
94
  "collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
95
95
  "config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
96
- "docs/guide.md": "545b050500a09ffdf7bc620e01f0b63bf59e92dfe855ba3201419b0bcc92a1fc",
96
+ "docs/guide.md": "186e2419f44ff5b13dfbcea709dc8616f3817c260717839a9fa51834ab73f718",
97
97
  "inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
98
- "lib/call.js": "7dfa0ae60be87d8f7f26b4360d5c1144322abe66eb61b233ed812e35bb8e59bd",
98
+ "lib/call.js": "87c11b8531e1a6d247d52d4e03ea8e5aa06a1af49cbd7cbd0af97e2586237f7b",
99
99
  "lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
100
100
  "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
- "lib/store.js": "270161f907c68de39d32758931e86c290582524af4650e722c2c3be209acc875",
102
- "plugin.js": "963e8190dbcaec71915d73402648899bb38135b25ec869a659d0938d03868682",
103
- "plugin.json": "704402863b0aec563046b347d4341680e2dc9739db967bec0a933c2bf0e4e5c9",
104
- "plugin.public.js": "5f2dc4f01202b16ae804364a33c547509d1fa4026eadebac440730234aa266b5",
101
+ "lib/store.js": "7aa7967e4694e5a3b85378f60302e5a47394b1d90c7dd86e09578156f1c13e03",
102
+ "plugin.js": "90424e681de6c086dcc316c734ddb907c7572e452949ced4cc756205a4798f6e",
103
+ "plugin.json": "8f7a5b1ed1e0bbfb1e933906b2c63bff4fbfc62868244b2077b1090d87fc3cc0",
104
+ "plugin.public.js": "4d5b4e8ad6943182ee3b6c6e6f6762a2d3f676d8d7ee363fcb07bd47d3d6532e",
105
105
  "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
106
  "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
107
107
  "public/lib/clean.js": "d591be9ea8462705b7bc8806670027fdd4d6d4764777da08e91a010d27ec1651",
@@ -110,7 +110,7 @@
110
110
  "public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
111
111
  "templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
112
112
  "tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
113
- "tests/api.test.js": "57ce73f48776e15454794405bae5b9674baeda46a96e4a11d0e253c6a977a764",
113
+ "tests/api.test.js": "43ae40477a43a03d21096038f317d745bc08cd51767c97548bf0601e99ebdd97",
114
114
  "tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
115
115
  "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
116
116
  }
@@ -10,7 +10,8 @@
10
10
  * GET /api/calls/contacts?q= contacts the caller may READ (own + shared), to pick (calls.create)
11
11
  * GET /api/calls/contact/:id one contact the caller may read, to fill in (calls.create)
12
12
  * POST /api/calls {name, email?, contactId?, notify} -> 201 {id, hostUrl, joinUrl, emailed, emailLimited?, expiresAt}
13
- * GET /api/calls?contact=<id> the caller's recent calls with that contact (calls.read)
13
+ * GET /api/calls?contact=<id> the caller's recent calls with that contact, with `messages` (calls.read)
14
+ * GET /api/calls/:id/chat the call's saved chat (read-only transcript) - its HOST or an admin (calls.read)
14
15
  *
15
16
  * Contacts are read through `readableContactIds` (services/hooks.js): the
16
17
  * caller's own, plus whatever a sharing plugin (Contacts Pro) lets them read -
@@ -24,18 +25,23 @@
24
25
  * The call itself (no sign-in - the link is the credential):
25
26
  * /api/calls/room/:id/{join,poll,signal,chat,answer,recording,leave} (plugins/_lib/call/routes.js)
26
27
  * join takes {t} (the guest) or {h} (the host); guests knock and the host lets them in.
28
+ * "Add people" (the host, in the call): GET /api/calls/room/:id/people?q= - the host's readable contacts and the
29
+ * team; POST /api/calls/room/:id/invite - each person gets their own Join link `?g=<pass>` (never the guest's
30
+ * token), by email, notification and Messages as chosen (services/callInvites.js). They knock like any guest.
27
31
  */
28
32
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
29
33
  import {getEntry, listEntries} from '../../services/collections.js';
30
34
  import {readableContactIds} from '../../services/hooks.js';
31
35
  import {getPermissionsFor} from '../../services/roles.js';
32
- import {getEffectiveRoles} from '../../services/userRoles.js';
36
+ import {getEffectiveLevel, getEffectiveRoles} from '../../services/userRoles.js';
33
37
  import {isToolEnabled} from '../../services/tools.js';
34
38
  import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
39
+ import {whoOf} from '../../../plugins/_lib/call/rooms.js';
35
40
  import {getBaseUrl} from '../public.js';
41
+ import {deliverInvites, resolveInvitees, searchInvitees} from '../../services/callInvites.js';
36
42
  import {
37
- EMAIL_RE, callMailReady, callPaths, callsOfContact, createCall, getCall, instantCallsEnabled, isOpen,
38
- mayEmailAddress, mayStartCall, sendCallEmail, whoIsCalling
43
+ EMAIL_RE, callChat, callMailReady, callPaths, callsOfContact, chatCountOf, createCall, getCall, instantCallsEnabled, isOpen,
44
+ invitePath, mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
39
45
  } from '../../services/instantCalls.js';
40
46
 
41
47
  const CONTACTS_SLUG = 'contacts-contacts';
@@ -122,13 +128,49 @@ export async function callsRoutes(fastify, opts = {}) {
122
128
  if (!contact) return fail(reply, 400, 'contact is required');
123
129
  const now = Date.now();
124
130
  const list = (await callsOfContact(contact, uidOf(request))).slice(0, 20)
125
- .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now)}));
131
+ .map(c => ({id: c.id, name: c.name, createdAt: c.createdAt, expiresAt: c.expiresAt, open: isOpen(c, now), messages: chatCountOf(c)}));
126
132
  return reply.send(list);
127
133
  });
128
134
 
135
+ // The call's chat, saved against it: for the host who made the call, or an admin. Anyone else: 404 (not 403 -
136
+ // whether a call exists is the host's business). `mine` marks the host's own lines for the bubbles.
137
+ fastify.get('/:id/chat', can('read'), async (request, reply) => {
138
+ const rec = await getCall(String(request.params.id));
139
+ const uid = uidOf(request);
140
+ let admin = false;
141
+ try { admin = getEffectiveLevel(request.user || {}) <= 1; } catch { admin = false; }
142
+ if (!rec || !uid || (rec.hostId !== uid && !admin)) return fail(reply, 404, 'That call could not be found.');
143
+ const hostWho = whoOf(`${rec.id}:host`, '');
144
+ const mineToo = rec.hostId === uid;
145
+ const items = (await callChat(rec.id)).map(({from, ...i}) => ({...i, mine: mineToo && i.kind === 'chat' && i.who === hostWho}));
146
+ reply.header('Cache-Control', 'no-store');
147
+ return reply.send({id: rec.id, name: rec.name, hostName: rec.hostName, createdAt: rec.createdAt, expiresAt: rec.expiresAt,
148
+ messages: chatCountOf(rec), items});
149
+ });
150
+
129
151
  // The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
130
152
  mountCallRoutes(fastify, {
131
153
  prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
132
- resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h})
154
+ resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h, g: body?.g}),
155
+ // The chat is saved against the call record, so a call reopened (or after a restart) shows it again.
156
+ chat: {load: (room) => callChat(String(room)), save: (room, item) => saveCallChat(String(room), item)},
157
+ // Add people (the host): the host user's contacts and the team; each invitee gets their own `?g=` link.
158
+ invite: {
159
+ search: async (room, q) => {
160
+ const rec = await getCall(String(room));
161
+ if (!rec || !isOpen(rec)) return [];
162
+ return searchInvitees({hostUserId: rec.hostId, q});
163
+ },
164
+ send: async (room, people, {via, request}) => {
165
+ const rec = await getCall(String(room));
166
+ if (!rec || !isOpen(rec)) return {error: 'This call has ended.'};
167
+ const base = getBaseUrl(request);
168
+ const newLink = async () => `${base}${await invitePath(rec.id)}`;
169
+ const targets = await resolveInvitees(people, {hostUserId: rec.hostId});
170
+ const results = await deliverInvites(targets, {linkFor: newLink, hostName: rec.hostName, hostUserId: rec.hostId,
171
+ title: `Video call: ${rec.hostName} and ${rec.name || 'a guest'}`, via, key: `instant:${rec.id}`, expiresAt: rec.expiresAt, log: request.log});
172
+ return {link: await newLink(), results};
173
+ }
174
+ }
133
175
  });
134
176
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * /_call/<id>?t=<guest token> the person called
5
5
  * /_call/<id>?h=<host key> the host
6
+ * /_call/<id>?g=<invite pass> someone the host added ("Add people") - names themselves, knocks
6
7
  *
7
8
  * Under `/_call`, like `/_preview`. Answered from an onRequest hook, and ONLY
8
9
  * while instant calls are on: with no Pro Tool with calls the request goes on
@@ -53,14 +54,14 @@ export const recoverScript = '<script>(function(){try{if(location.search)return;
53
54
 
54
55
  export function callPage(rec, who, cred, now = Date.now()) {
55
56
  if (!rec || !who) {
56
- const bare = !cred?.t && !cred?.h;
57
+ const bare = !cred?.t && !cred?.h && !cred?.g;
57
58
  return {status: 404, title: 'Call', body: '<h1>This call link is not valid</h1><p>Ask the person who sent it for a new one.</p>'
58
59
  + (bare ? recoverScript : '')};
59
60
  }
60
61
  if (!(Date.parse(rec.expiresAt) > now)) {
61
62
  return {status: 410, title: 'Call', body: '<h1>This call has ended</h1><p>Ask the person who sent the link for a new one.</p>'};
62
63
  }
63
- const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && !rec.name, recordingAllowed: false, closesAt: rec.expiresAt};
64
+ const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && (!rec.name || who.invited === true), recordingAllowed: false, closesAt: rec.expiresAt};
64
65
  return {
65
66
  status: 200,
66
67
  title: who.title,
@@ -89,7 +90,7 @@ export async function callPublicRoutes(fastify, opts = {}) {
89
90
  reply.header('cache-control', 'no-store, private');
90
91
  reply.header('x-robots-tag', 'noindex, nofollow');
91
92
  const q = new URLSearchParams(search);
92
- const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
+ const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : q.get('g') ? {g: q.get('g').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
94
  const rec = await getCall(id);
94
95
  const page = callPage(rec, whoIsCalling(rec, cred), cred);
95
96
  const html = await renderBlogPage(TEMPLATE, {body: page.body}, {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * "Add people" to a call - what core does for every Tool with calls (instant
3
+ * calls, Booking, Calendar, Messages). The pure parts (the request's shape, the
4
+ * invite pass, matching, limits) are `plugins/_lib/call/invite.js`; the routes
5
+ * are the engine's (`plugins/_lib/call/routes.js`). This file finds people and
6
+ * sends what the host chose:
7
+ *
8
+ * searchInvitees({hostUserId, q, excludeUserIds, contacts, team})
9
+ * the host's readable contacts (their own plus what a sharing Tool lets
10
+ * them read - `readableContactIds`) and the team, matched to `q`
11
+ * resolveInvitees(people, {hostUserId, contacts, team, emails})
12
+ * the request's {kind, id} / {kind: 'email'} list as real people - a
13
+ * contact the host may read, an active team member - else an error row
14
+ * deliverInvites(targets, {linkFor, hostName, hostUserId, title, via, key})
15
+ * per person: the Join email (via.email, any address), a notification
16
+ * (via.notify, team members: bell + desktop, `core:call-invites`,
17
+ * dedupeKey per call + person) and a direct message from the host
18
+ * (via.message, team members, when a Tool registered a messenger -
19
+ * Messages; hooks.sendDirectMessage). Never throws.
20
+ *
21
+ * "The team": active users whose most senior role is at the base `user` level
22
+ * or above - roles a plugin makes for people from outside (Job Board's
23
+ * candidates) are not the team.
24
+ *
25
+ * @module services/callInvites
26
+ */
27
+ import {getConfig} from '../config.js';
28
+ import {getEntry, listEntries} from './collections.js';
29
+ import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
30
+ import {hasDirectMessenger, readableContactIds, sendDirectMessage} from './hooks.js';
31
+ import {notify, registerNotificationSource} from './notify.js';
32
+ import {getPermissionsFor, getRoleLevel} from './roles.js';
33
+ import {isToolEnabled} from './tools.js';
34
+ import {getUserById, listUsers} from './users.js';
35
+ import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
36
+ import {cleanText, createLimiter, inviteMessage, inviteTitle, matchPeople} from '../../plugins/_lib/call/invite.js';
37
+
38
+ export const SOURCE = 'core:call-invites';
39
+ const CONTACTS_SLUG = 'contacts-contacts';
40
+ /** Join emails one address may be sent in an hour, whoever sends them. */
41
+ export const EMAILS_PER_ADDRESS_HOUR = 3;
42
+ /** Join emails the whole site may send in an hour (every call, every Tool) - a leaked host link cannot spam. */
43
+ export const EMAILS_PER_SITE_HOUR = 30;
44
+
45
+ try {
46
+ registerNotificationSource({id: SOURCE, owner: 'core', label: 'Call invitations', severity: 'info', defaultOn: true,
47
+ description: 'Someone in a video call adds a team member to it ("Add people") - told to that person, with the Join link.'});
48
+ } catch { /* never stops the calls */ }
49
+
50
+ const isActive = (u) => Boolean(u) && u.disabled !== true && u.active !== false && u.isActive !== false;
51
+
52
+ /** Is this user one of the team (active, at the base user level or more senior)? */
53
+ export function isTeamMember(u) {
54
+ if (!isActive(u)) return false;
55
+ const base = getRoleLevel('user');
56
+ try { return getEffectiveLevel(u) <= (Number.isFinite(base) ? base : 2); } catch { return false; }
57
+ }
58
+
59
+ /** The team, by name. */
60
+ export async function teamMembers() {
61
+ return (await listUsers().catch(() => [])).filter(isTeamMember);
62
+ }
63
+
64
+ /** May this user read contacts at all (Contacts on, a role with contacts.read)? */
65
+ function mayReadContacts(user) {
66
+ try {
67
+ const roles = getEffectiveRoles(user || {});
68
+ return isToolEnabled('contacts') && roles.some(r => getPermissionsFor('contacts', 'read').includes(r));
69
+ } catch { return false; }
70
+ }
71
+
72
+ /** The contacts this user may read, as {id, name, email} (with an email only). */
73
+ export async function contactsOf(hostUserId) {
74
+ const user = hostUserId ? await getUserById(String(hostUserId)).catch(() => null) : null;
75
+ if (!user || !mayReadContacts(user)) return [];
76
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000}).catch(() => ({entries: []}));
77
+ const withEmail = (entries || []).filter(e => String(e?.data?.email || '').includes('@'));
78
+ const ok = await readableContactIds(withEmail, {id: user.id, roles: getEffectiveRoles(user)});
79
+ return withEmail.filter(e => ok.has(e.id)).map(e => ({id: e.id, name: String(e.data?.name || '').trim(), email: String(e.data.email).trim()}));
80
+ }
81
+
82
+ /**
83
+ * Search: the team (not `excludeUserIds`, not the host) and the host's contacts.
84
+ *
85
+ * @param {{hostUserId?: string, q?: string, excludeUserIds?: string[], contacts?: boolean, team?: boolean, teamFilter?: (u: object) => boolean}} opts
86
+ */
87
+ export async function searchInvitees({hostUserId = '', q = '', excludeUserIds = [], contacts = true, team = true, teamFilter = null} = {}) {
88
+ const users = team ? (await teamMembers()).filter(u => !teamFilter || teamFilter(u)) : [];
89
+ const known = contacts && String(q || '').trim() ? await contactsOf(hostUserId) : [];
90
+ return matchPeople({q, users, contacts: known, excludeUserIds: [...excludeUserIds, hostUserId].filter(Boolean),
91
+ canMessage: Boolean(hostUserId) && hasDirectMessenger()});
92
+ }
93
+
94
+ /**
95
+ * The request's people as real people: `{kind, id?, name, email, userId?}`, or `{name, error}` for someone who
96
+ * cannot be invited (a contact the host may not read, not a team member, an email not allowed here).
97
+ *
98
+ * @param {object[]} people - cleanInviteRequest()'s list
99
+ * @param {{hostUserId?: string, contacts?: boolean, team?: boolean, emails?: boolean, teamFilter?: (u: object) => boolean}} opts
100
+ */
101
+ export async function resolveInvitees(people = [], {hostUserId = '', contacts = true, team = true, emails = true, teamFilter = null} = {}) {
102
+ const out = [];
103
+ let readable = null;
104
+ for (const p of people) {
105
+ if (p.kind === 'email') {
106
+ out.push(emails ? {kind: 'email', name: p.name, email: p.email} : {name: p.name, error: 'Only team members can be added to this call.'});
107
+ } else if (p.kind === 'user') {
108
+ const u = team ? await getUserById(p.id).catch(() => null) : null;
109
+ if (!u || !isTeamMember(u) || (teamFilter && !teamFilter(u)) || u.id === hostUserId) out.push({name: 'Someone', error: 'That person cannot be added.'});
110
+ else out.push({kind: 'user', id: u.id, name: String(u.name || '').trim() || 'Team member', email: String(u.email || '').trim()});
111
+ } else if (p.kind === 'contact') {
112
+ readable ||= contacts ? await contactsOf(hostUserId) : [];
113
+ const c = readable.find(x => x.id === p.id);
114
+ out.push(c ? {kind: 'contact', id: c.id, name: c.name || c.email.split('@')[0], email: c.email.toLowerCase()} : {name: 'Someone', error: 'That contact could not be found.'});
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The Join email
122
+ // ---------------------------------------------------------------------------
123
+
124
+ let mailer = null;
125
+ let transport = null;
126
+ let transportFor = '';
127
+ const emailsTo = createLimiter({max: EMAILS_PER_ADDRESS_HOUR, windowMs: 3600000});
128
+ const emailsFromSite = createLimiter({max: EMAILS_PER_SITE_HOUR, windowMs: 3600000});
129
+
130
+ /** Tests only: `fn(msg)` receives every message instead of SMTP. null restores. */
131
+ export function setInviteMailer(fn) { mailer = fn; }
132
+ /** Tests only. */
133
+ export function resetInviteLimits() { emailsTo.reset(); emailsFromSite.reset(); }
134
+
135
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
136
+
137
+ /** Can mail really leave this site? */
138
+ export const inviteMailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
139
+
140
+ /** The message, escaped. */
141
+ export function composeInviteEmail({name = '', hostName = '', title = '', link = ''}) {
142
+ const host = cleanText(hostName, 80) || 'Someone';
143
+ name = cleanText(name, 120);
144
+ title = cleanText(title, 120);
145
+ const subject = `${host} is inviting you to a video call now`;
146
+ const lines = [`Hello${name ? ` ${name}` : ''}, ${host} is in a video call${title ? ` (${title})` : ''} and would like you to join now.`,
147
+ 'Use the button below. You will wait a moment until you are let in. You need a camera and microphone, or you can join with just one.'];
148
+ const html = [...lines.map(l => `<p>${escapeHtml(l)}</p>`),
149
+ `<p><a href="${escapeHtml(link)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">Join the call</a></p>`,
150
+ `<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(link)}</p>`].join('\n');
151
+ return {subject, html, text: [...lines, `Join the call: ${link}`].join('\n\n')};
152
+ }
153
+
154
+ /** Send it. Never throws; true only when it left. */
155
+ export async function sendInviteEmail({to, name, hostName, title, link}, {log} = {}) {
156
+ try {
157
+ if (!to || !link) return false;
158
+ const msg = {to, ...composeInviteEmail({name, hostName, title, link})};
159
+ if (mailer) { await mailer(msg); return true; }
160
+ const s = site();
161
+ const smtp = s.smtp || {};
162
+ if (!isSmtpConfigured(smtp)) return false;
163
+ const k = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
164
+ if (!transport || transportFor !== k) { transport = await createTransport(smtp); transportFor = k; }
165
+ await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Calls', ...msg});
166
+ return true;
167
+ } catch (err) {
168
+ try { (log?.error || console.error).call(log || console, `[calls] Invitation to ${to} not sent: ${err?.message || err}`); } catch { /* never */ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Sending
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Tell each person. `linkFor(target)` is their Join link (absolute). Resolves to one result per target:
179
+ * `{name, emailed, notified, messaged, error?}` - `error` says why nothing reached them.
180
+ *
181
+ * @param {object[]} targets - resolveInvitees()'s list
182
+ * @param {{linkFor: (t: object) => string|Promise<string>, hostName?: string, hostUserId?: string, title?: string,
183
+ * via?: {email?: boolean, notify?: boolean, message?: boolean}, key?: string, expiresAt?: string, log?: object,
184
+ * deps?: {notify?: Function, sendDirectMessage?: Function}}} opts - `key`: the call, for the notification's dedupeKey
185
+ */
186
+ export async function deliverInvites(targets = [], {linkFor, hostName = '', hostUserId = '', title = '', via = {}, key = '', expiresAt, log, deps = {}} = {}) {
187
+ const raise = deps.notify || notify;
188
+ const dm = deps.sendDirectMessage || sendDirectMessage;
189
+ const results = [];
190
+ for (const t of targets) {
191
+ if (t.error) { results.push({name: t.name, emailed: false, notified: false, messaged: false, error: t.error}); continue; }
192
+ const r = {name: t.name, emailed: false, notified: false, messaged: false};
193
+ let link = '';
194
+ try { link = await linkFor(t); } catch { link = ''; }
195
+ if (!link) { results.push({...r, error: 'No link could be made for them.'}); continue; }
196
+ const why = [];
197
+ if (via.email && t.email) {
198
+ if (!inviteMailReady()) why.push('email is not set up on this site');
199
+ else if (!emailsTo.take(t.email)) why.push('they have been emailed several times this hour');
200
+ else if (!emailsFromSite.take('site')) why.push('this site has sent a lot of call invitations this hour');
201
+ else if (!(r.emailed = await sendInviteEmail({to: t.email, name: t.name, hostName, title, link}, {log}))) why.push('the email could not be sent');
202
+ }
203
+ if (t.kind === 'user') {
204
+ if (via.notify) {
205
+ try {
206
+ r.notified = Boolean(await raise({title: inviteTitle(hostName), body: title ? `Video call: ${title}` : 'A video call, now.', severity: 'info', link,
207
+ source: SOURCE, dedupeKey: `call-invite:${key}:${t.id}`.slice(0, 200), audience: {userIds: [t.id]},
208
+ expiresAt: expiresAt || new Date(Date.now() + 3 * 3600e3).toISOString()}));
209
+ } catch { r.notified = false; }
210
+ if (!r.notified) why.push('the notification was not raised');
211
+ }
212
+ if (via.message) {
213
+ if (!hostUserId) why.push('a message needs a host who is a team member');
214
+ else if (!hasDirectMessenger() && !deps.sendDirectMessage) why.push('Messages is not on');
215
+ else {
216
+ let out = null;
217
+ try { out = await dm({fromUserId: hostUserId, toUserId: t.id, text: inviteMessage(hostName, title, link)}); } catch { out = null; }
218
+ r.messaged = out === true || out?.sent === true;
219
+ if (!r.messaged) why.push(`no message: ${out?.reason || 'it could not be sent'}`);
220
+ }
221
+ }
222
+ }
223
+ if (!r.emailed && !r.notified && !r.messaged) r.error = why.length ? `Not reached: ${why.join('; ')}.` : 'Nothing was sent - copy the link and pass it on.';
224
+ results.push(r);
225
+ }
226
+ return results;
227
+ }
228
+
229
+ /**
230
+ * The origin Join links are built on: the site's configured `baseUrl` whenever there is one (links in emails
231
+ * and notices must never follow a Host header), else the request's own origin.
232
+ */
233
+ export function inviteOrigin(request) {
234
+ const base = String(site().baseUrl || '').trim();
235
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
236
+ if (!request) return '';
237
+ return `${request.protocol}://${request.host || request.headers?.host || request.hostname}`;
238
+ }
@@ -404,6 +404,61 @@ export async function readableContactIds(entries, viewer) {
404
404
  return out;
405
405
  }
406
406
 
407
+ // ---------------------------------------------------------------------------
408
+ // 7c. Direct messages - one team member writing to another (Messages)
409
+ // ---------------------------------------------------------------------------
410
+ //
411
+ // Core has no messaging of its own. A Tool that has it (Messages) registers a
412
+ // messenger, and core code - a call's "Add people" (services/callInvites.js) -
413
+ // sends a direct message from one user to another through it, without knowing
414
+ // the Tool. No messenger (Messages off or absent): sendDirectMessage answers
415
+ // false and the caller carries on without it.
416
+
417
+ const _messengers = new Map(); // owner -> fn({fromUserId, toUserId, text}) => Promise<boolean | {sent, reason}>
418
+
419
+ /**
420
+ * @param {string} owner - the plugin
421
+ * @param {(msg: {fromUserId: string, toUserId: string, text: string}) => Promise<boolean|{sent: boolean, reason?: string}>} fn
422
+ * post `text` from `fromUserId` to `toUserId` in their direct conversation (made when there is none), by the
423
+ * Tool's OWN rules (who may message whom): true / {sent: true} when posted, else false or {sent: false, reason}
424
+ */
425
+ export function registerDirectMessenger(owner, fn) {
426
+ if (typeof owner !== 'string' || !owner || typeof fn !== 'function') return false;
427
+ _messengers.set(owner, fn);
428
+ return true;
429
+ }
430
+
431
+ /** @param {string} owner */
432
+ export function unregisterDirectMessenger(owner) {
433
+ _messengers.delete(owner);
434
+ }
435
+
436
+ /** Is there a Tool that can send a direct message? */
437
+ export const hasDirectMessenger = () => _messengers.size > 0;
438
+
439
+ /**
440
+ * Send a direct message through the registered messenger. Never throws.
441
+ *
442
+ * @param {{fromUserId: string, toUserId: string, text: string}} msg
443
+ * @returns {Promise<{sent: boolean, reason?: string}>} sent when a messenger posted it; else why not
444
+ */
445
+ export async function sendDirectMessage({fromUserId, toUserId, text} = {}) {
446
+ const from = String(fromUserId || '');
447
+ const to = String(toUserId || '');
448
+ const body = String(text || '').trim().slice(0, 4000);
449
+ if (!from || !to || from === to || !body) return {sent: false, reason: 'nobody to send it to'};
450
+ if (!_messengers.size) return {sent: false, reason: 'Messages is not on'};
451
+ let reason = 'the message could not be sent';
452
+ for (const fn of _messengers.values()) {
453
+ try {
454
+ const out = await fn({fromUserId: from, toUserId: to, text: body});
455
+ if (out === true || out?.sent === true) return {sent: true};
456
+ if (out?.reason) reason = String(out.reason).slice(0, 200);
457
+ } catch { /* a failing messenger sends nothing */ }
458
+ }
459
+ return {sent: false, reason};
460
+ }
461
+
407
462
  // ---------------------------------------------------------------------------
408
463
  // 8. Sitemap sources (0.89) - URLs a plugin serves, in /sitemap.xml
409
464
  // ---------------------------------------------------------------------------
@@ -19,7 +19,13 @@
19
19
  * actions and list stand aside (checked per request), so nobody sees two.
20
20
  *
21
21
  * A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
22
- * createdAt, expiresAt}. Kept in memory and in one JSON file with the sign-in
22
+ * createdAt, expiresAt, chat?, inviteKey?}. `inviteKey` (made on the first "Add
23
+ * people", `inviteKeyOf`) signs the passes of people added to the call
24
+ * (`?g=<pass>`, _lib/call/invite.js) - never the guest's token, which carries
25
+ * the guest's name and place. `chat` is the call's chat, saved against the
26
+ * call (the engine's items - typed messages plus joined/left lines - the last
27
+ * CHAT_MAX); the engine seeds a reopened room from it, and the host (or an
28
+ * admin) reads it afterwards through GET /api/calls/:id/chat. Kept in memory and in one JSON file with the sign-in
23
29
  * sessions (content/sessions/ - runtime state, never committed; see
24
30
  * siteGitignore.js), so a restart keeps the links working. The call itself (who
25
31
  * is in it) is the call engine's, in memory. Records are forgotten KEEP_DAYS
@@ -40,10 +46,13 @@ import {hasRelatedProvider, registerRelatedItems, unregisterRelatedItems} from '
40
46
  import {getPermissionsFor} from './roles.js';
41
47
  import {getEffectiveRoles} from './userRoles.js';
42
48
  import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
49
+ import {checkInvitePass, invitePass, newInviteKey} from '../../plugins/_lib/call/invite.js';
43
50
 
44
51
  export const INSTANT_HOURS = 3;
45
52
  export const KEEP_DAYS = 30;
46
53
  export const MAX_KEPT = 2000;
54
+ /** Chat items kept on one call record (the engine's HISTORY_MAX). */
55
+ export const CHAT_MAX = 500;
47
56
  export const CALL_PATH = '/_call';
48
57
  export const ROOM_API = '/api/calls/room';
49
58
  export const ID_RE = /^[\w-]{16,40}$/;
@@ -141,9 +150,14 @@ function registerActions() {
141
150
  async list({id, user}) {
142
151
  if (!user?.id || !mayCall(user, 'read')) return [];
143
152
  const now = Date.now();
144
- return (await callsOfContact(id, String(user.id))).slice(0, 5).map(c => ({
145
- id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : ''
146
- }));
153
+ return (await callsOfContact(id, String(user.id))).slice(0, 5).map((c) => {
154
+ const n = chatCountOf(c);
155
+ return {
156
+ id: c.id, title: 'Video call', startAt: c.createdAt, past: !isOpen(c, now), meta: isOpen(c, now) ? 'open now' : '',
157
+ // The admin's Calls list offers "N messages" - a read-only transcript (GET /api/calls/:id/chat).
158
+ ...(n ? {transcript: {url: `/calls/${encodeURIComponent(c.id)}/chat`, count: n, title: `Video call with ${c.name || 'a guest'}`}} : {})
159
+ };
160
+ });
147
161
  }
148
162
  });
149
163
  // An email in the mail reader: "Call Sam now…" with the sender filled in.
@@ -234,6 +248,65 @@ export async function getCall(id) {
234
248
  return (await load()).get(id) || null;
235
249
  }
236
250
 
251
+ /** The call's invite key, made (and stored) the first time someone is added to it. Null for no such call. */
252
+ export async function inviteKeyOf(id) {
253
+ if (typeof id !== 'string' || !ID_RE.test(id)) return null;
254
+ const all = await load();
255
+ const rec = all.get(id);
256
+ if (!rec) return null;
257
+ if (!rec.inviteKey) {
258
+ rec.inviteKey = newInviteKey();
259
+ await save(all);
260
+ }
261
+ return rec.inviteKey;
262
+ }
263
+
264
+ /** A new Join link (relative) for someone added to the call: `?g=<pass>`. */
265
+ export async function invitePath(id) {
266
+ const k = await inviteKeyOf(id);
267
+ return k ? `${CALL_PATH}/${encodeURIComponent(id)}?g=${encodeURIComponent(invitePass(k, id))}` : '';
268
+ }
269
+
270
+ /** The chat item as it is kept on a record; null for junk. System lines other than joined/left are not kept. */
271
+ function chatItem(raw) {
272
+ if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
273
+ const kind = raw.kind === 'system' ? 'system' : 'chat';
274
+ if (kind === 'system' && raw.event !== 'joined' && raw.event !== 'left') return null;
275
+ const item = {id: String(raw.id).slice(0, 64), seq: Number(raw.seq) || 0, at: String(raw.at || ''), kind,
276
+ from: String(raw.from || '').slice(0, 64), who: String(raw.who || '').slice(0, 64),
277
+ name: String(raw.name || '').slice(0, 60), text: raw.text.slice(0, 1000)};
278
+ if (kind === 'system') item.event = raw.event;
279
+ return item;
280
+ }
281
+
282
+ /**
283
+ * Save one chat item against its call (the engine's `chat.save`). Queued with
284
+ * every other write to the file; the last CHAT_MAX are kept. A call that is no
285
+ * longer on record (forgotten, or never) keeps nothing.
286
+ */
287
+ export async function saveCallChat(id, raw) {
288
+ const item = chatItem(raw);
289
+ if (!item || typeof id !== 'string' || !ID_RE.test(id)) return;
290
+ const all = await load();
291
+ const rec = all.get(id);
292
+ if (!rec) return;
293
+ const chat = Array.isArray(rec.chat) ? rec.chat : [];
294
+ if (chat.some(c => c?.id === item.id)) return;
295
+ chat.push(item);
296
+ if (chat.length > CHAT_MAX) chat.splice(0, chat.length - CHAT_MAX);
297
+ rec.chat = chat;
298
+ await save(all);
299
+ }
300
+
301
+ /** A call's saved chat, oldest first ([] for none). */
302
+ export async function callChat(id) {
303
+ const rec = await getCall(id);
304
+ return Array.isArray(rec?.chat) ? rec.chat.map(c => ({...c})) : [];
305
+ }
306
+
307
+ /** How many typed messages a call's chat holds (not the joined/left lines). */
308
+ export const chatCountOf = (rec) => (Array.isArray(rec?.chat) ? rec.chat.filter(c => c?.kind === 'chat').length : 0);
309
+
237
310
  /** One host's calls with one contact, newest first. */
238
311
  export async function callsOfContact(contactId, hostId) {
239
312
  if (!contactId || !hostId) return [];
@@ -249,8 +322,10 @@ const same = (a, b) => {
249
322
 
250
323
  /**
251
324
  * Who the caller is in an instant call, or null (the engine answers 404).
252
- * `cred` is what the link carried: {t} for the guest, {h} for the host. The
253
- * engine answers 410 once `closesAt` has passed.
325
+ * `cred` is what the link carried: {t} for the guest, {h} for the host, {g}
326
+ * for someone added to the call (a pass under `inviteKey`: they give their own
327
+ * name and knock; each pass is its own identity). The engine answers 410 once
328
+ * `closesAt` has passed.
254
329
  */
255
330
  export function whoIsCalling(rec, cred = {}) {
256
331
  if (!rec) return null;
@@ -264,6 +339,8 @@ export function whoIsCalling(rec, cred = {}) {
264
339
  if (cred.t && same(cred.t, rec.token)) {
265
340
  return {...base, host: false, name: rec.name || '', identity: `${rec.id}:guest`};
266
341
  }
342
+ const nonce = cred.g && rec.inviteKey ? checkInvitePass(rec.inviteKey, rec.id, cred.g) : null;
343
+ if (nonce) return {...base, host: false, name: '', identity: `${rec.id}:inv:${nonce}`, invited: true};
267
344
  return null;
268
345
  }
269
346