domma-cms 1.8.32 → 1.8.34
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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import defaultConfig from './config.js';
|
|
4
|
+
import {createEntry as rawCreate, deleteEntry as rawDelete, getEntry, listEntries, updateEntry as rawUpdate} from '../../server/services/collections.js';
|
|
5
|
+
import {getUserById, listUsers} from '../../server/services/users.js';
|
|
6
|
+
import {getEffectiveLevel} from '../../server/services/userRoles.js';
|
|
7
|
+
import {getConfig} from '../../server/config.js';
|
|
8
|
+
import {cleanAttachments, indexByThread, unreadBadge, unreadKey, unreadNotices} from './admin/lib/messages.js';
|
|
9
|
+
import {checkInvite, checkJoin, endedText, inviteLink, joinLink, loadSecret, MAX_IN_CALL, RING_MS, whoIsCalling} from './lib/call.js';
|
|
10
|
+
|
|
11
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
const THREADS_SLUG = 'user-message-threads';
|
|
14
|
+
const MESSAGES_SLUG = 'user-messages';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// SSE subscriber bus — Map<userId, Set<res>>
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const subscribers = new Map();
|
|
20
|
+
|
|
21
|
+
function subscribe(userId, res) {
|
|
22
|
+
if (!subscribers.has(userId)) subscribers.set(userId, new Set());
|
|
23
|
+
subscribers.get(userId).add(res);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function unsubscribe(userId, res) {
|
|
27
|
+
const set = subscribers.get(userId);
|
|
28
|
+
if (!set) return;
|
|
29
|
+
set.delete(res);
|
|
30
|
+
if (set.size === 0) subscribers.delete(userId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Push an SSE event to every active connection for the listed user IDs.
|
|
35
|
+
* Broken sockets are evicted silently.
|
|
36
|
+
*/
|
|
37
|
+
function broadcast(userIds, eventName, payload) {
|
|
38
|
+
const data = JSON.stringify(payload);
|
|
39
|
+
for (const uid of userIds) {
|
|
40
|
+
const set = subscribers.get(uid);
|
|
41
|
+
if (!set) continue;
|
|
42
|
+
for (const res of set) {
|
|
43
|
+
try {
|
|
44
|
+
res.write(`event: ${eventName}\ndata: ${data}\n\n`);
|
|
45
|
+
} catch {
|
|
46
|
+
set.delete(res);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (set.size === 0) subscribers.delete(uid);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Writes go through these, so the message cache below is dropped on every one
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
let messagesCache = null; // {at, entries, byThread} - every message, briefly, and the same list keyed by thread
|
|
58
|
+
const dropCache = () => { messagesCache = null; };
|
|
59
|
+
const createEntry = (...a) => rawCreate(...a).finally(dropCache);
|
|
60
|
+
const updateEntry = (...a) => rawUpdate(...a).finally(dropCache);
|
|
61
|
+
const deleteEntry = (...a) => rawDelete(...a).finally(dropCache);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every message, kept 30 seconds: the sidebar asks for the unread badge every
|
|
65
|
+
* minute from every open admin tab, and each ask read the whole collection.
|
|
66
|
+
* Any write through this plugin drops it at once, so nothing stale is shown
|
|
67
|
+
* after a send, a read or a delete.
|
|
68
|
+
*/
|
|
69
|
+
async function allMessages() {
|
|
70
|
+
if (messagesCache && Date.now() - messagesCache.at < 30_000) return messagesCache.entries;
|
|
71
|
+
const {entries} = await listEntries(MESSAGES_SLUG, {limit: 100000});
|
|
72
|
+
// Indexed once per fill (1.7.3): before, every thread read, mark-read, prune and the per-thread unread count
|
|
73
|
+
// in GET /threads filtered the whole site's messages again - O(threads x messages) per list of conversations.
|
|
74
|
+
messagesCache = {at: Date.now(), entries, byThread: indexByThread(entries)};
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Messages keyed by threadId (Map), from the same cache fill as allMessages(). */
|
|
79
|
+
async function messagesByThread() {
|
|
80
|
+
await allMessages();
|
|
81
|
+
return messagesCache.byThread;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One conversation's messages (every status, undeleted or not) without scanning the others'. */
|
|
85
|
+
async function threadMessages(threadId) {
|
|
86
|
+
return (await messagesByThread()).get(threadId) || [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The site's address for links in notices and emails: `site.baseUrl`, else the request's. */
|
|
90
|
+
function siteOrigin(request) {
|
|
91
|
+
let base = '';
|
|
92
|
+
try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
|
|
93
|
+
if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
|
|
94
|
+
return `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Admin by effective level (roles are data; the JWT carries no `level`). */
|
|
98
|
+
function isAdminUser(request) {
|
|
99
|
+
try { return getEffectiveLevel(request.user) <= 1; } catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Helpers
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function toThread(entry) {
|
|
107
|
+
return {id: entry.id, ...entry.data, createdAt: entry.meta?.createdAt};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toMessage(entry) {
|
|
111
|
+
return {id: entry.id, ...entry.data};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The longest message, and conversation name, the API keeps (1.7.2 - before, only Fastify's 1 MB body limit). */
|
|
115
|
+
const MAX_BODY = 10000;
|
|
116
|
+
const MAX_NAME = 80;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve minimal user summaries for a list of IDs: id, name, avatar - never the email (1.7.2). /people hides
|
|
120
|
+
* emails so a self-registered member cannot collect the site's addresses; /threads and /count handed them out
|
|
121
|
+
* for everyone in the member's conversations, which a managed group with staff in it made a directory.
|
|
122
|
+
*/
|
|
123
|
+
async function resolveUsers(ids) {
|
|
124
|
+
const map = {};
|
|
125
|
+
await Promise.all(ids.map(async id => {
|
|
126
|
+
try {
|
|
127
|
+
const u = await getUserById(id);
|
|
128
|
+
if (u) map[id] = {id, name: String(u.name || '').trim() || 'Unnamed member', avatar: u.avatar ?? null};
|
|
129
|
+
} catch { /* user deleted — omit */ }
|
|
130
|
+
}));
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Lazy retention pruning — deletes messages older than effectiveRetentionDays. */
|
|
135
|
+
async function pruneOldMessages(threadData, config) {
|
|
136
|
+
const ceiling = config.globalRetentionCeilingDays;
|
|
137
|
+
// A stored value below a day (written before 1.2 refused it) is read as the default.
|
|
138
|
+
const asked = Number(threadData.retentionDays);
|
|
139
|
+
const effective = Math.min(
|
|
140
|
+
Number.isFinite(asked) && asked >= 1 ? asked : config.defaultRetentionDays,
|
|
141
|
+
ceiling
|
|
142
|
+
);
|
|
143
|
+
const cutoff = new Date(Date.now() - effective * 86_400_000).toISOString();
|
|
144
|
+
const stale = (await threadMessages(threadData.id)).filter(e => e.data.createdAt < cutoff);
|
|
145
|
+
// One at a time: the file adapter rewrites the whole collection on each call,
|
|
146
|
+
// and parallel read-modify-writes lose all but one of them.
|
|
147
|
+
for (const e of stale) await deleteEntry(MESSAGES_SLUG, e.id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Verify caller is a participant of a thread, returning the thread entry or null. */
|
|
151
|
+
async function getThreadForUser(threadId, userId) {
|
|
152
|
+
const entry = await getEntry(THREADS_SLUG, threadId);
|
|
153
|
+
if (!entry) return null;
|
|
154
|
+
if (!Array.isArray(entry.data.participants) || !entry.data.participants.includes(userId)) return null;
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Plugin
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export default async function messagesPlugin(fastify, options) {
|
|
163
|
+
const {authenticate: signedIn, requireAdmin, requirePermission} = options.auth;
|
|
164
|
+
// Every route: signed in AND holding the Messages permission (granted to
|
|
165
|
+
// admin and user; take it from a role under Roles). Until 1.2 any signed-in
|
|
166
|
+
// account could message - including roles made for people outside the
|
|
167
|
+
// business. An older core without requirePermission keeps the old rule.
|
|
168
|
+
const mayUse = typeof requirePermission === 'function' ? requirePermission('messages', 'read') : async () => {};
|
|
169
|
+
async function authenticate(request, reply) {
|
|
170
|
+
const out = await signedIn(request, reply);
|
|
171
|
+
if (reply.sent) return out;
|
|
172
|
+
return mayUse(request, reply);
|
|
173
|
+
}
|
|
174
|
+
const config = {...defaultConfig, ...(options.settings ?? {})};
|
|
175
|
+
|
|
176
|
+
function me(request) {
|
|
177
|
+
return request.user?.id ?? request.user?.sub ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- Notifications (1.3; core 0.80+, feature-detected) ----
|
|
181
|
+
// A message left unread for 10 minutes: every 15 minutes, one notice per
|
|
182
|
+
// conversation to each person with one waiting - to THEM only. Reading the
|
|
183
|
+
// conversation clears it (and desktop notifications carry it off-screen).
|
|
184
|
+
const hooks = options.hooks || {};
|
|
185
|
+
const resolveNotice = (key) => { try { hooks.resolveNotification?.(key, 'unread'); } catch { /* never breaks messaging */ } };
|
|
186
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
hooks.registerNotificationSource({
|
|
189
|
+
id: 'unread', label: 'Unread messages', severity: 'info', every: '15m',
|
|
190
|
+
description: 'A message left unread for 10 minutes - told to its recipient, once per conversation. Goes when it is read.',
|
|
191
|
+
check: async (ctx) => {
|
|
192
|
+
const {entries: threads} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
193
|
+
const msgs = await allMessages();
|
|
194
|
+
const names = Object.fromEntries((await listUsers().catch(() => [])).map(u => [u.id, u.name || 'Someone']));
|
|
195
|
+
const notices = unreadNotices(threads.map(toThread), msgs.map(toMessage), names, ctx.now?.getTime?.() ?? Date.now());
|
|
196
|
+
for (const p of notices) await ctx.notify(p);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
} catch { /* older engine */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* SSE-compatible auth: falls back to ?token= query param when the
|
|
204
|
+
* Authorization header is absent (native EventSource cannot set headers).
|
|
205
|
+
*/
|
|
206
|
+
async function authenticateSSE(request, reply) {
|
|
207
|
+
if (!request.headers.authorization && request.query.token) {
|
|
208
|
+
request.headers.authorization = `Bearer ${request.query.token}`;
|
|
209
|
+
}
|
|
210
|
+
return authenticate(request, reply);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The people this user may start a conversation with - the one rule for
|
|
215
|
+
* the picker (/people) and for POST /threads, which until 1.2 took any id
|
|
216
|
+
* it was sent. An admin: everyone active. Anyone else: staff (admins),
|
|
217
|
+
* people who share one of their roles, and people they already talk to.
|
|
218
|
+
*/
|
|
219
|
+
async function reachable(request) {
|
|
220
|
+
return reachableFor({...(request.user || {}), id: me(request)});
|
|
221
|
+
}
|
|
222
|
+
/** reachable() for any account ({id, role, additionalRoles}) - the call's "Add people" (1.7) has no JWT. */
|
|
223
|
+
async function reachableFor(who) {
|
|
224
|
+
const users = (await listUsers().catch(() => [])).filter(u => u.disabled !== true && u.active !== false && u.isActive !== false);
|
|
225
|
+
const uid = who?.id;
|
|
226
|
+
if (isAdminUser({user: who})) return users.filter(u => u.id !== uid);
|
|
227
|
+
const rolesOf = (u) => new Set([u.role, ...(Array.isArray(u.additionalRoles) ? u.additionalRoles : [])].filter(Boolean));
|
|
228
|
+
const mine = rolesOf(who || {});
|
|
229
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
230
|
+
const known = new Set(entries.map(toThread)
|
|
231
|
+
.filter(t => Array.isArray(t.participants) && t.participants.includes(uid))
|
|
232
|
+
.flatMap(t => t.participants));
|
|
233
|
+
const staff = (u) => { try { return getEffectiveLevel(u) <= 1; } catch { return false; } };
|
|
234
|
+
return users.filter(u => u.id !== uid && (staff(u) || known.has(u.id) || [...rolesOf(u)].some(r => mine.has(r))));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// -----------------------------------------------------------------------
|
|
238
|
+
// Calls (1.4): a video call in a conversation. The engine is core's
|
|
239
|
+
// (_lib/call, 0.103+), mounted at the end; an older core has no calls.
|
|
240
|
+
// A room is a conversation. What is running is kept here, in memory, as
|
|
241
|
+
// the engine keeps its rooms: a restart ends every call.
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const dataDir = options.dataDir || path.join(HERE, 'data');
|
|
245
|
+
let secret = null;
|
|
246
|
+
const callSecret = () => (secret ??= loadSecret(dataDir));
|
|
247
|
+
/** threadId -> {startedAt, by, byName, messageId, joinedAt, in, participants} */
|
|
248
|
+
const live = new Map();
|
|
249
|
+
let calls = null;
|
|
250
|
+
|
|
251
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
252
|
+
try {
|
|
253
|
+
hooks.registerNotificationSource({id: 'calls', label: 'Calls', severity: 'info', defaultOn: true,
|
|
254
|
+
description: 'Someone starts a call in a conversation - told to the others in it. Goes when the call ends.'});
|
|
255
|
+
} catch { /* older engine */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function nameOf(userId) {
|
|
259
|
+
try { return String((await getUserById(userId))?.name || '').trim() || 'Someone'; } catch { return 'Someone'; }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const callInfo = (id) => {
|
|
263
|
+
const st = live.get(id);
|
|
264
|
+
return st ? {by: st.by, byName: st.byName, startedAt: new Date(st.startedAt).toISOString(), in: st.in, messageId: st.messageId} : null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A "call started / ended" line in the conversation. Read by everyone
|
|
269
|
+
* already: the live notice and the notification do the telling, so a
|
|
270
|
+
* call line never adds to the unread badge or the unread reminders.
|
|
271
|
+
*/
|
|
272
|
+
/**
|
|
273
|
+
* Set a thread's last-message fields from a FRESH read - never write back a copy read before an await, or a
|
|
274
|
+
* member removed meanwhile would be put back (participants is the thread's own list).
|
|
275
|
+
*/
|
|
276
|
+
async function touchThread(threadId, fields) {
|
|
277
|
+
const fresh = await getEntry(THREADS_SLUG, threadId).catch(() => null);
|
|
278
|
+
if (fresh) await updateEntry(THREADS_SLUG, threadId, {...fresh.data, ...fields});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function postCallLine(threadId, senderId, body, call, bringBack) {
|
|
282
|
+
const entry = await getEntry(THREADS_SLUG, threadId).catch(() => null);
|
|
283
|
+
if (!entry) return null;
|
|
284
|
+
const participants = entry.data.participants ?? [];
|
|
285
|
+
const now = new Date().toISOString();
|
|
286
|
+
const msgEntry = await createEntry(MESSAGES_SLUG, {
|
|
287
|
+
threadId, senderId, kind: 'call', call, body, attachments: [],
|
|
288
|
+
createdAt: now, editedAt: null, deletedAt: null, readBy: [...participants]
|
|
289
|
+
}, {createdBy: senderId, source: 'messages'});
|
|
290
|
+
await touchThread(threadId, {...(bringBack ? {archivedBy: []} : {}), lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
|
|
291
|
+
const message = toMessage(msgEntry);
|
|
292
|
+
broadcast(participants, 'message:new', {threadId, message});
|
|
293
|
+
return message;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Start a conversation's call, or hand back the one running. One line, one notice, however many ask at once. */
|
|
297
|
+
async function startCall(entry, userId) {
|
|
298
|
+
const id = entry.id;
|
|
299
|
+
if (live.has(id)) return live.get(id);
|
|
300
|
+
const participants = [...(entry.data.participants ?? [])];
|
|
301
|
+
// Held before the first await: a second click, or a second person, finds it running.
|
|
302
|
+
const st = {startedAt: Date.now(), by: userId, byName: 'Someone', messageId: null, joinedAt: 0, in: 0, participants, invited: new Set()};
|
|
303
|
+
live.set(id, st);
|
|
304
|
+
st.byName = await nameOf(userId);
|
|
305
|
+
const line = await postCallLine(id, userId, `${st.byName} started a call`, {event: 'started'}, true).catch(err => {
|
|
306
|
+
fastify.log?.warn?.(`[messages] call line not written: ${err?.message || err}`);
|
|
307
|
+
return null;
|
|
308
|
+
});
|
|
309
|
+
st.messageId = line?.id ?? null;
|
|
310
|
+
broadcast(participants, 'call:started', {threadId: id, call: callInfo(id)});
|
|
311
|
+
const others = participants.filter(p => p !== userId);
|
|
312
|
+
if (others.length) {
|
|
313
|
+
const group = entry.data.kind !== 'dm';
|
|
314
|
+
try {
|
|
315
|
+
hooks.notify?.({
|
|
316
|
+
title: `${st.byName} is calling${group && entry.data.name ? ` - ${entry.data.name}` : ''}`.slice(0, 200),
|
|
317
|
+
body: 'Open Messages and click Join.',
|
|
318
|
+
severity: 'info', link: '#/plugins/messages', source: 'calls',
|
|
319
|
+
dedupeKey: `call:${id}`, audience: {userIds: others}
|
|
320
|
+
});
|
|
321
|
+
} catch { /* a notice never breaks a call */ }
|
|
322
|
+
}
|
|
323
|
+
return st;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function endCall(id) {
|
|
327
|
+
const st = live.get(id);
|
|
328
|
+
if (!st) return;
|
|
329
|
+
live.delete(id);
|
|
330
|
+
// Nobody is "in the call" any more: the next call's lines are unread for everyone not in THAT one.
|
|
331
|
+
present.delete(id);
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
await postCallLine(id, st.by, endedText(st.joinedAt || st.startedAt, now, Boolean(st.joinedAt)),
|
|
334
|
+
{event: 'ended', minutes: st.joinedAt ? Math.round((now - st.joinedAt) / 60e3) : 0}, false)
|
|
335
|
+
.catch(err => fastify.log?.warn?.(`[messages] call line not written: ${err?.message || err}`));
|
|
336
|
+
// A new call may have started while the line was written (someone clicked Call, or joined with their
|
|
337
|
+
// link): it has the same notice key, so this one's ending must not clear it or say "ended".
|
|
338
|
+
if (live.has(id)) return;
|
|
339
|
+
broadcast(st.participants, 'call:ended', {threadId: id});
|
|
340
|
+
try { hooks.resolveNotification?.(`call:${id}`, 'calls'); } catch { /* never breaks messaging */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Every few seconds: how many are in each call; an emptied call (or one nobody came to) ends. */
|
|
344
|
+
let sweeping = false;
|
|
345
|
+
async function sweepCalls(now = Date.now()) {
|
|
346
|
+
if (sweeping || !calls) return;
|
|
347
|
+
sweeping = true;
|
|
348
|
+
try {
|
|
349
|
+
for (const [id, st] of [...live]) {
|
|
350
|
+
const n = calls.countRoom(id).in;
|
|
351
|
+
if (n > 0 && !st.joinedAt) st.joinedAt = now;
|
|
352
|
+
if (n !== st.in) {
|
|
353
|
+
st.in = n;
|
|
354
|
+
broadcast(st.participants, 'call:updated', {threadId: id, call: callInfo(id)});
|
|
355
|
+
}
|
|
356
|
+
if (n === 0 && (st.joinedAt || now - st.startedAt > RING_MS)) await endCall(id);
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
sweeping = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
// SSE stream
|
|
365
|
+
// -----------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
fastify.get('/stream', {preHandler: [authenticateSSE]}, async (request, reply) => {
|
|
368
|
+
const userId = me(request);
|
|
369
|
+
|
|
370
|
+
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
|
371
|
+
reply.raw.setHeader('Cache-Control', 'no-cache');
|
|
372
|
+
reply.raw.setHeader('Connection', 'keep-alive');
|
|
373
|
+
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
|
374
|
+
reply.raw.flushHeaders();
|
|
375
|
+
|
|
376
|
+
subscribe(userId, reply.raw);
|
|
377
|
+
|
|
378
|
+
const keepalive = setInterval(() => {
|
|
379
|
+
try { reply.raw.write(': ping\n\n'); } catch { clearInterval(keepalive); }
|
|
380
|
+
}, 25_000);
|
|
381
|
+
|
|
382
|
+
request.raw.on('close', () => {
|
|
383
|
+
clearInterval(keepalive);
|
|
384
|
+
unsubscribe(userId, reply.raw);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Keep the connection open — do NOT call reply.send()
|
|
388
|
+
await new Promise(resolve => request.raw.on('close', resolve));
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// -----------------------------------------------------------------------
|
|
392
|
+
// Threads — list
|
|
393
|
+
// -----------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
fastify.get('/threads', {preHandler: [authenticate]}, async (request, reply) => {
|
|
396
|
+
const userId = me(request);
|
|
397
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
398
|
+
|
|
399
|
+
const threads = entries
|
|
400
|
+
.map(toThread)
|
|
401
|
+
.filter(t => Array.isArray(t.participants) && t.participants.includes(userId))
|
|
402
|
+
.sort((a, b) => (b.lastMessageAt ?? '') > (a.lastMessageAt ?? '') ? 1 : -1);
|
|
403
|
+
|
|
404
|
+
// Resolve participant names + per-thread unread counts
|
|
405
|
+
const allUserIds = [...new Set(threads.flatMap(t => t.participants ?? []))];
|
|
406
|
+
const userMap = await resolveUsers(allUserIds);
|
|
407
|
+
|
|
408
|
+
const byThread = await messagesByThread();
|
|
409
|
+
const withMeta = threads.map(t => {
|
|
410
|
+
const unread = (byThread.get(t.id) || []).filter(e =>
|
|
411
|
+
!e.data.deletedAt &&
|
|
412
|
+
!(e.data.readBy ?? []).includes(userId)
|
|
413
|
+
).length;
|
|
414
|
+
return {...t, participants: (t.participants ?? []).map(id => userMap[id] ?? {id}), unread, call: callInfo(t.id)};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return reply.send(withMeta);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
421
|
+
// Threads — create
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
fastify.post('/threads', {preHandler: [authenticate]}, async (request, reply) => {
|
|
425
|
+
const userId = me(request);
|
|
426
|
+
const {kind = 'dm', name, participants = []} = request.body ?? {};
|
|
427
|
+
|
|
428
|
+
if (!['dm', 'group-adhoc', 'group-managed'].includes(kind)) {
|
|
429
|
+
return reply.code(400).send({error: 'Invalid kind'});
|
|
430
|
+
}
|
|
431
|
+
if (kind === 'group-managed' && !isAdminUser(request)) {
|
|
432
|
+
return reply.code(403).send({error: 'Only admins can create managed groups'});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Only real, active people this user may reach - never an id guessed or made up.
|
|
436
|
+
if (!Array.isArray(participants) || participants.some(p => typeof p !== 'string' || !p) || participants.length > 50) {
|
|
437
|
+
return reply.code(400).send({error: 'participants must be a list of user ids'});
|
|
438
|
+
}
|
|
439
|
+
const allowed = new Set((await reachable(request)).map(u => u.id));
|
|
440
|
+
if (participants.some(p => p !== userId && !allowed.has(p))) {
|
|
441
|
+
return reply.code(403).send({error: 'You cannot message one of those people'});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Ensure current user is in the list
|
|
445
|
+
const allParticipants = [...new Set([userId, ...participants])];
|
|
446
|
+
|
|
447
|
+
if (kind === 'dm') {
|
|
448
|
+
if (allParticipants.length !== 2) {
|
|
449
|
+
return reply.code(400).send({error: 'DMs require exactly one other participant'});
|
|
450
|
+
}
|
|
451
|
+
// Deduplicate: return existing DM if one already exists
|
|
452
|
+
const sorted = [...allParticipants].sort();
|
|
453
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
454
|
+
const existing = entries.find(e =>
|
|
455
|
+
e.data.kind === 'dm' &&
|
|
456
|
+
Array.isArray(e.data.participants) &&
|
|
457
|
+
[...e.data.participants].sort().join('|') === sorted.join('|')
|
|
458
|
+
);
|
|
459
|
+
if (existing) return reply.send(toThread(existing));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if ((kind === 'group-adhoc' || kind === 'group-managed') && !name?.trim()) {
|
|
463
|
+
return reply.code(400).send({error: 'Groups require a name'});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const now = new Date().toISOString();
|
|
467
|
+
const entry = await createEntry(THREADS_SLUG, {
|
|
468
|
+
kind,
|
|
469
|
+
name: name?.trim() ?? null,
|
|
470
|
+
participants: kind === 'dm' ? [...allParticipants].sort() : allParticipants,
|
|
471
|
+
createdBy: userId,
|
|
472
|
+
retentionDays: null,
|
|
473
|
+
lastMessageAt: now,
|
|
474
|
+
lastMessagePreview: ''
|
|
475
|
+
}, {createdBy: userId, source: 'messages'});
|
|
476
|
+
|
|
477
|
+
const thread = toThread(entry);
|
|
478
|
+
|
|
479
|
+
// Notify other participants
|
|
480
|
+
const others = allParticipants.filter(id => id !== userId);
|
|
481
|
+
broadcast(others, 'thread:created', thread);
|
|
482
|
+
|
|
483
|
+
return reply.code(201).send(thread);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// -----------------------------------------------------------------------
|
|
487
|
+
// Threads — update (rename / change retention)
|
|
488
|
+
// -----------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
fastify.put('/threads/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
491
|
+
const userId = me(request);
|
|
492
|
+
const entry = await getThreadForUser(request.params.id, userId);
|
|
493
|
+
if (!entry) return reply.code(404).send({error: 'Thread not found'});
|
|
494
|
+
|
|
495
|
+
const {name, retentionDays} = request.body ?? {};
|
|
496
|
+
const data = {...entry.data};
|
|
497
|
+
|
|
498
|
+
// Only thread creator can rename ad-hoc; admins can rename managed
|
|
499
|
+
if (name !== undefined) {
|
|
500
|
+
if (data.kind !== 'dm' && (data.createdBy === userId || isAdminUser(request))) {
|
|
501
|
+
data.name = String(name).trim().slice(0, MAX_NAME);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// How long messages are kept is the creator's (or an admin's) to say, and
|
|
506
|
+
// never less than a day: until 1.2 any participant could set it, and a
|
|
507
|
+
// negative number put the cut-off in the future - the next read deleted
|
|
508
|
+
// every message in the conversation, for everyone.
|
|
509
|
+
if (retentionDays !== undefined) {
|
|
510
|
+
if (data.createdBy !== userId && !isAdminUser(request)) {
|
|
511
|
+
return reply.code(403).send({error: 'Only whoever started this conversation can change how long it keeps messages'});
|
|
512
|
+
}
|
|
513
|
+
if (retentionDays === null || retentionDays === '') {
|
|
514
|
+
data.retentionDays = null;
|
|
515
|
+
} else {
|
|
516
|
+
const days = Number(retentionDays);
|
|
517
|
+
if (!Number.isInteger(days) || days < 1) return reply.code(400).send({error: 'retentionDays must be a whole number of days, 1 or more'});
|
|
518
|
+
data.retentionDays = Math.min(days, config.globalRetentionCeilingDays);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const updated = await updateEntry(THREADS_SLUG, entry.id, data);
|
|
523
|
+
const thread = toThread(updated);
|
|
524
|
+
broadcast(data.participants, 'thread:updated', thread);
|
|
525
|
+
return reply.send(thread);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// -----------------------------------------------------------------------
|
|
529
|
+
// Threads — leave / delete
|
|
530
|
+
// -----------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
fastify.delete('/threads/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
533
|
+
const userId = me(request);
|
|
534
|
+
const entry = await getThreadForUser(request.params.id, userId);
|
|
535
|
+
if (!entry) return reply.code(404).send({error: 'Thread not found'});
|
|
536
|
+
|
|
537
|
+
const data = {...entry.data};
|
|
538
|
+
data.participants = data.participants.filter(id => id !== userId);
|
|
539
|
+
|
|
540
|
+
if (data.participants.length === 0) {
|
|
541
|
+
// Last participant — purge thread and all its messages
|
|
542
|
+
for (const e of await threadMessages(entry.id)) await deleteEntry(MESSAGES_SLUG, e.id);
|
|
543
|
+
await deleteEntry(THREADS_SLUG, entry.id);
|
|
544
|
+
// Nobody left to call: the call (if any) ends, with no line to write it in.
|
|
545
|
+
live.delete(entry.id);
|
|
546
|
+
calls?.closeRoom(entry.id);
|
|
547
|
+
} else {
|
|
548
|
+
await updateEntry(THREADS_SLUG, entry.id, data);
|
|
549
|
+
broadcast(data.participants, 'thread:updated', toThread({id: entry.id, data}));
|
|
550
|
+
// Out of the conversation, out of its call - and no more news of it.
|
|
551
|
+
calls?.kickFromRoom?.(entry.id, `${entry.id}:${userId}`);
|
|
552
|
+
const st = live.get(entry.id);
|
|
553
|
+
if (st) st.participants = st.participants.filter(p => p !== userId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return reply.send({success: true});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// -----------------------------------------------------------------------
|
|
560
|
+
// Messages — list (paginated)
|
|
561
|
+
// -----------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
fastify.get('/threads/:id/messages', {preHandler: [authenticate]}, async (request, reply) => {
|
|
564
|
+
const userId = me(request);
|
|
565
|
+
const entry = await getThreadForUser(request.params.id, userId);
|
|
566
|
+
if (!entry) return reply.code(404).send({error: 'Thread not found'});
|
|
567
|
+
|
|
568
|
+
// Lazy retention pruning
|
|
569
|
+
await pruneOldMessages({id: entry.id, ...entry.data}, config);
|
|
570
|
+
|
|
571
|
+
const {before, limit = 50} = request.query;
|
|
572
|
+
let msgs = (await threadMessages(entry.id))
|
|
573
|
+
.map(toMessage)
|
|
574
|
+
.sort((a, b) => (a.createdAt ?? '') < (b.createdAt ?? '') ? -1 : 1);
|
|
575
|
+
|
|
576
|
+
if (before) msgs = msgs.filter(m => m.createdAt < before);
|
|
577
|
+
msgs = msgs.slice(-Math.min(Number(limit) || 50, 200));
|
|
578
|
+
|
|
579
|
+
return reply.send(msgs);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// -----------------------------------------------------------------------
|
|
583
|
+
// Messages — send
|
|
584
|
+
// -----------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
fastify.post('/threads/:id/messages', {preHandler: [authenticate]}, async (request, reply) => {
|
|
587
|
+
const userId = me(request);
|
|
588
|
+
const threadEntry = await getThreadForUser(request.params.id, userId);
|
|
589
|
+
if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
|
|
590
|
+
|
|
591
|
+
const body = String(request.body?.body ?? '').slice(0, MAX_BODY);
|
|
592
|
+
// Only media names and file facts are kept - the address is rebuilt from the name,
|
|
593
|
+
// so a message can never carry a javascript: or off-site link into someone's screen.
|
|
594
|
+
const attachments = cleanAttachments(request.body?.attachments);
|
|
595
|
+
if (!body.trim() && attachments.length === 0) {
|
|
596
|
+
return reply.code(400).send({error: 'Message must have a body or attachment'});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const now = new Date().toISOString();
|
|
600
|
+
const msgEntry = await createEntry(MESSAGES_SLUG, {
|
|
601
|
+
threadId: threadEntry.id,
|
|
602
|
+
senderId: userId,
|
|
603
|
+
body: String(body).trim(),
|
|
604
|
+
attachments,
|
|
605
|
+
createdAt: now,
|
|
606
|
+
editedAt: null,
|
|
607
|
+
deletedAt: null,
|
|
608
|
+
readBy: [userId]
|
|
609
|
+
}, {createdBy: userId, source: 'messages'});
|
|
610
|
+
|
|
611
|
+
// Update thread preview
|
|
612
|
+
const preview = body.trim().slice(0, 120)
|
|
613
|
+
|| (attachments.length ? `Attachment: ${attachments[0].filename}`.slice(0, 120) : '');
|
|
614
|
+
// A new message brings an archived conversation back to everyone's inbox, as mail does.
|
|
615
|
+
await updateEntry(THREADS_SLUG, threadEntry.id, {
|
|
616
|
+
...threadEntry.data,
|
|
617
|
+
archivedBy: [],
|
|
618
|
+
lastMessageAt: now,
|
|
619
|
+
lastMessagePreview: preview
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const message = toMessage(msgEntry);
|
|
623
|
+
const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
|
|
624
|
+
broadcast(others, 'message:new', {threadId: threadEntry.id, message});
|
|
625
|
+
|
|
626
|
+
return reply.code(201).send(message);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// -----------------------------------------------------------------------
|
|
630
|
+
// Messages — edit
|
|
631
|
+
// -----------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
fastify.put('/threads/:id/messages/:msgId', {preHandler: [authenticate]}, async (request, reply) => {
|
|
634
|
+
const userId = me(request);
|
|
635
|
+
const threadEntry = await getThreadForUser(request.params.id, userId);
|
|
636
|
+
if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
|
|
637
|
+
|
|
638
|
+
const msgEntry = await getEntry(MESSAGES_SLUG, request.params.msgId);
|
|
639
|
+
if (!msgEntry || msgEntry.data.threadId !== threadEntry.id) {
|
|
640
|
+
return reply.code(404).send({error: 'Message not found'});
|
|
641
|
+
}
|
|
642
|
+
if (msgEntry.data.senderId !== userId) {
|
|
643
|
+
return reply.code(403).send({error: 'Cannot edit another user\'s message'});
|
|
644
|
+
}
|
|
645
|
+
if (msgEntry.data.deletedAt) {
|
|
646
|
+
return reply.code(403).send({error: 'Cannot edit a deleted message'});
|
|
647
|
+
}
|
|
648
|
+
if (msgEntry.data.kind === 'call') {
|
|
649
|
+
return reply.code(403).send({error: 'A call line cannot be edited'});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const ageMs = Date.now() - new Date(msgEntry.data.createdAt).getTime();
|
|
653
|
+
if (ageMs > config.editWindowMinutes * 60_000) {
|
|
654
|
+
return reply.code(403).send({error: `Edit window of ${config.editWindowMinutes} minutes has passed`});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const {body} = request.body ?? {};
|
|
658
|
+
const now = new Date().toISOString();
|
|
659
|
+
const updated = await updateEntry(MESSAGES_SLUG, msgEntry.id, {
|
|
660
|
+
...msgEntry.data,
|
|
661
|
+
body: body !== undefined ? String(body).trim().slice(0, MAX_BODY) : msgEntry.data.body,
|
|
662
|
+
editedAt: now
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const message = toMessage(updated);
|
|
666
|
+
const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
|
|
667
|
+
broadcast(others, 'message:edited', {threadId: threadEntry.id, message});
|
|
668
|
+
|
|
669
|
+
return reply.send(message);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// -----------------------------------------------------------------------
|
|
673
|
+
// Messages — soft delete
|
|
674
|
+
// -----------------------------------------------------------------------
|
|
675
|
+
|
|
676
|
+
fastify.delete('/threads/:id/messages/:msgId', {preHandler: [authenticate]}, async (request, reply) => {
|
|
677
|
+
const userId = me(request);
|
|
678
|
+
const threadEntry = await getThreadForUser(request.params.id, userId);
|
|
679
|
+
if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
|
|
680
|
+
|
|
681
|
+
const msgEntry = await getEntry(MESSAGES_SLUG, request.params.msgId);
|
|
682
|
+
if (!msgEntry || msgEntry.data.threadId !== threadEntry.id) {
|
|
683
|
+
return reply.code(404).send({error: 'Message not found'});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The sender, an admin - or in a group, whoever started it. Never the other
|
|
687
|
+
// side of a one-to-one conversation: starting it first is not moderation.
|
|
688
|
+
const groupOwner = threadEntry.data.kind !== 'dm' && threadEntry.data.createdBy === userId;
|
|
689
|
+
if (msgEntry.data.senderId !== userId && !groupOwner && !isAdminUser(request)) {
|
|
690
|
+
return reply.code(403).send({error: 'Cannot delete this message'});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const now = new Date().toISOString();
|
|
694
|
+
const updated = await updateEntry(MESSAGES_SLUG, msgEntry.id, {
|
|
695
|
+
...msgEntry.data,
|
|
696
|
+
body: '',
|
|
697
|
+
attachments: [],
|
|
698
|
+
deletedAt: now
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const message = toMessage(updated);
|
|
702
|
+
const others = (threadEntry.data.participants ?? []).filter(id => id !== userId);
|
|
703
|
+
broadcast(others, 'message:deleted', {threadId: threadEntry.id, message});
|
|
704
|
+
|
|
705
|
+
return reply.send(message);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// -----------------------------------------------------------------------
|
|
709
|
+
// Calls (1.4) - start this conversation's call, or join the one running.
|
|
710
|
+
// Answers the caller's OWN short-lived link to the call page.
|
|
711
|
+
// -----------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
fastify.post('/threads/:id/call', {preHandler: [authenticate], config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
714
|
+
const userId = me(request);
|
|
715
|
+
const entry = await getThreadForUser(request.params.id, userId);
|
|
716
|
+
if (!entry) return reply.code(404).send({error: 'Thread not found'});
|
|
717
|
+
if (!calls) return reply.code(501).send({error: 'Calls need Domma CMS 0.103 or later.'});
|
|
718
|
+
await startCall(entry, userId);
|
|
719
|
+
return reply.send({url: joinLink(callSecret(), entry.id, userId), call: callInfo(entry.id), max: MAX_IN_CALL});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// -----------------------------------------------------------------------
|
|
723
|
+
// Messages — mark read
|
|
724
|
+
// -----------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
fastify.post('/threads/:id/read', {preHandler: [authenticate]}, async (request, reply) => {
|
|
727
|
+
const userId = me(request);
|
|
728
|
+
const threadEntry = await getThreadForUser(request.params.id, userId);
|
|
729
|
+
if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
|
|
730
|
+
|
|
731
|
+
const unread = (await threadMessages(threadEntry.id)).filter(e =>
|
|
732
|
+
!e.data.deletedAt &&
|
|
733
|
+
!(e.data.readBy ?? []).includes(userId)
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Sequential for the same reason as pruning: parallel updates lose all but one.
|
|
737
|
+
for (const e of unread) {
|
|
738
|
+
await updateEntry(MESSAGES_SLUG, e.id, {...e.data, readBy: [...(e.data.readBy ?? []), userId]});
|
|
739
|
+
}
|
|
740
|
+
resolveNotice(unreadKey(threadEntry.id, userId));
|
|
741
|
+
|
|
742
|
+
return reply.send({unread: 0});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// -----------------------------------------------------------------------
|
|
746
|
+
// Who am I here - the screen's rights come from the server, not from the
|
|
747
|
+
// stored user (which carries a role name, not a level).
|
|
748
|
+
// -----------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
fastify.get('/me', {preHandler: [authenticate]}, async (request) => ({
|
|
751
|
+
id: me(request),
|
|
752
|
+
name: await nameOf(me(request)), // 1.5: Call… names a new group after everyone in it
|
|
753
|
+
isAdmin: isAdminUser(request),
|
|
754
|
+
editWindowMinutes: config.editWindowMinutes,
|
|
755
|
+
defaultRetentionDays: config.defaultRetentionDays,
|
|
756
|
+
globalRetentionCeilingDays: config.globalRetentionCeilingDays
|
|
757
|
+
}));
|
|
758
|
+
|
|
759
|
+
// People to start a conversation with. /api/users needs the users
|
|
760
|
+
// permission, which most people who message do not hold - so the picker
|
|
761
|
+
// was empty for them. Id and NAME only - never an email (a self-registered
|
|
762
|
+
// member must not be able to collect the site's addresses). An admin sees
|
|
763
|
+
// everyone; anyone else sees staff (admins), people who share one of their
|
|
764
|
+
// roles, and people they already have a conversation with.
|
|
765
|
+
fastify.get('/people', {preHandler: [authenticate]}, async (request) => {
|
|
766
|
+
return (await reachable(request)).map(u => ({id: u.id, name: String(u.name || '').trim() || 'Unnamed member'}));
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// The sidebar badge (countUrl): unread in conversations this user has not archived.
|
|
770
|
+
fastify.get('/count', {preHandler: [authenticate]}, async (request) => {
|
|
771
|
+
const userId = me(request);
|
|
772
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
773
|
+
const mine = entries.map(toThread).filter(t => Array.isArray(t.participants) && t.participants.includes(userId));
|
|
774
|
+
const users = await resolveUsers([...new Set(mine.flatMap(t => t.participants))]);
|
|
775
|
+
const msgs = await allMessages();
|
|
776
|
+
const unreadBy = {};
|
|
777
|
+
for (const e of msgs) {
|
|
778
|
+
if (e.data.deletedAt || (e.data.readBy ?? []).includes(userId)) continue;
|
|
779
|
+
unreadBy[e.data.threadId] = (unreadBy[e.data.threadId] || 0) + 1;
|
|
780
|
+
}
|
|
781
|
+
return unreadBadge(mine.map(t => ({...t, participants: t.participants.map(id => users[id] ?? {id}), unread: unreadBy[t.id] || 0})), userId);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Mark unread: the newest message from someone else counts as unread again.
|
|
785
|
+
fastify.post('/threads/:id/unread', {preHandler: [authenticate]}, async (request, reply) => {
|
|
786
|
+
const userId = me(request);
|
|
787
|
+
const threadEntry = await getThreadForUser(request.params.id, userId);
|
|
788
|
+
if (!threadEntry) return reply.code(404).send({error: 'Thread not found'});
|
|
789
|
+
const latest = (await threadMessages(threadEntry.id))
|
|
790
|
+
.filter(e => !e.data.deletedAt && e.data.senderId !== userId)
|
|
791
|
+
.sort((a, b) => String(b.data.createdAt).localeCompare(String(a.data.createdAt)))[0];
|
|
792
|
+
if (!latest) return reply.code(409).send({error: 'Nothing from anyone else to mark unread'});
|
|
793
|
+
await updateEntry(MESSAGES_SLUG, latest.id, {...latest.data, readBy: (latest.data.readBy ?? []).filter(id => id !== userId)});
|
|
794
|
+
return reply.send({unread: 1});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Archive / unarchive - per person: the others still see the conversation.
|
|
798
|
+
fastify.post('/threads/:id/archive', {preHandler: [authenticate]}, async (request, reply) => {
|
|
799
|
+
const userId = me(request);
|
|
800
|
+
const entry = await getThreadForUser(request.params.id, userId);
|
|
801
|
+
if (!entry) return reply.code(404).send({error: 'Thread not found'});
|
|
802
|
+
const archived = request.body?.archived !== false;
|
|
803
|
+
const others = (entry.data.archivedBy ?? []).filter(id => id !== userId);
|
|
804
|
+
const data = {...entry.data, archivedBy: archived ? [...others, userId] : others};
|
|
805
|
+
await updateEntry(THREADS_SLUG, entry.id, data);
|
|
806
|
+
if (archived) resolveNotice(unreadKey(entry.id, userId)); // put away: not waiting on them any more
|
|
807
|
+
return reply.send({archived});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// -----------------------------------------------------------------------
|
|
811
|
+
// Managed groups (admin only)
|
|
812
|
+
// -----------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
fastify.get('/managed-groups', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
|
|
815
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
816
|
+
const groups = entries
|
|
817
|
+
.filter(e => e.data.kind === 'group-managed')
|
|
818
|
+
.map(toThread)
|
|
819
|
+
.sort((a, b) => (a.name ?? '') < (b.name ?? '') ? -1 : 1);
|
|
820
|
+
return reply.send(groups);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
fastify.post('/managed-groups', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
|
|
824
|
+
const userId = me(request);
|
|
825
|
+
const {name, participants = []} = request.body ?? {};
|
|
826
|
+
|
|
827
|
+
if (!name?.trim()) return reply.code(400).send({error: 'Group name is required'});
|
|
828
|
+
|
|
829
|
+
const allParticipants = [...new Set([userId, ...participants])];
|
|
830
|
+
const now = new Date().toISOString();
|
|
831
|
+
|
|
832
|
+
const entry = await createEntry(THREADS_SLUG, {
|
|
833
|
+
kind: 'group-managed',
|
|
834
|
+
name: name.trim(),
|
|
835
|
+
participants: allParticipants,
|
|
836
|
+
createdBy: userId,
|
|
837
|
+
retentionDays: null,
|
|
838
|
+
lastMessageAt: now,
|
|
839
|
+
lastMessagePreview: ''
|
|
840
|
+
}, {createdBy: userId, source: 'messages'});
|
|
841
|
+
|
|
842
|
+
const thread = toThread(entry);
|
|
843
|
+
const others = allParticipants.filter(id => id !== userId);
|
|
844
|
+
broadcast(others, 'thread:created', thread);
|
|
845
|
+
|
|
846
|
+
return reply.code(201).send(thread);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// -----------------------------------------------------------------------
|
|
850
|
+
// Calls (1.4): the engine's routes, under /call. Who may join: someone
|
|
851
|
+
// holding a link made for them (POST /threads/:id/call) who is still in
|
|
852
|
+
// the conversation. Joining a conversation whose call is not running
|
|
853
|
+
// (given up unanswered, or the site restarted) starts it again.
|
|
854
|
+
// -----------------------------------------------------------------------
|
|
855
|
+
// The call's chat (1.6) lives IN the conversation: what someone types in the call is posted as an ordinary
|
|
856
|
+
// message from them (saved with `callChat: {id, seq, who}` - the engine's item), so it is in the thread
|
|
857
|
+
// afterwards, once. System lines are not posted (the started/ended lines say it). The engine hands save()
|
|
858
|
+
// the joiner's identity, `<thread>:<user>` (never sent to a browser), which names the sender. Everyone in
|
|
859
|
+
// the call at the time (tracked from the joined/left lines) has already read it; the others get it unread.
|
|
860
|
+
// load() gives the call the conversation's earlier call messages, so a call reopened shows them.
|
|
861
|
+
const present = new Map(); // threadId -> Set<userId> in the call now
|
|
862
|
+
const callChat = {
|
|
863
|
+
async load(threadId) {
|
|
864
|
+
const msgs = (await threadMessages(threadId)).filter(e => e.data.callChat && !e.data.deletedAt)
|
|
865
|
+
.map(toMessage).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))).slice(-500);
|
|
866
|
+
const names = {};
|
|
867
|
+
for (const id of new Set(msgs.map(m => m.senderId))) names[id] = await nameOf(id);
|
|
868
|
+
return msgs.map(m => ({id: String(m.callChat.id || m.id), seq: Number(m.callChat.seq) || 0, at: m.createdAt, kind: 'chat',
|
|
869
|
+
from: '', who: String(m.callChat.who || ''), name: names[m.senderId] || 'Someone', text: String(m.body || '')}));
|
|
870
|
+
},
|
|
871
|
+
async save(threadId, item, meta = {}) {
|
|
872
|
+
const identity = String(meta.identity || '');
|
|
873
|
+
const userId = identity.startsWith(`${threadId}:`) ? identity.slice(threadId.length + 1) : '';
|
|
874
|
+
if (!userId) return;
|
|
875
|
+
if (item.kind === 'system') {
|
|
876
|
+
const inCall = present.get(threadId) || new Set();
|
|
877
|
+
if (item.event === 'joined') inCall.add(userId);
|
|
878
|
+
else if (item.event === 'left') inCall.delete(userId);
|
|
879
|
+
if (inCall.size) present.set(threadId, inCall); else present.delete(threadId);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (item.kind !== 'chat' || !String(item.text || '').trim()) return;
|
|
883
|
+
// Still in the conversation (someone taken out of it is taken out of its call too).
|
|
884
|
+
const threadEntry = await getThreadForUser(threadId, userId);
|
|
885
|
+
if (!threadEntry) return;
|
|
886
|
+
const now = new Date().toISOString();
|
|
887
|
+
const body = String(item.text).trim().slice(0, 1000);
|
|
888
|
+
const readBy = [...new Set([userId, ...(present.get(threadId) || [])])]
|
|
889
|
+
.filter(id => (threadEntry.data.participants ?? []).includes(id));
|
|
890
|
+
const msgEntry = await createEntry(MESSAGES_SLUG, {
|
|
891
|
+
threadId, senderId: userId, body, attachments: [], createdAt: now, editedAt: null, deletedAt: null, readBy,
|
|
892
|
+
callChat: {id: String(item.id).slice(0, 64), seq: Number(item.seq) || 0, who: String(item.who || '').slice(0, 64)}
|
|
893
|
+
}, {createdBy: userId, source: 'messages'});
|
|
894
|
+
await touchThread(threadId, {archivedBy: [], lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
|
|
895
|
+
// Everyone - the sender too: their Messages screen did not send it.
|
|
896
|
+
broadcast(threadEntry.data.participants ?? [], 'message:new', {threadId, message: toMessage(msgEntry)});
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
const {mountCallRoutes} = await import('../_lib/call/routes.js');
|
|
902
|
+
// Add people (1.7; a core with services/callInvites.js): anyone in the call rings a team member who is not in
|
|
903
|
+
// the conversation - someone they may reach (reachableFor). The invitee gets a notification, a direct message
|
|
904
|
+
// (Messages itself, through core's messenger hook) and, when ticked, the email - each with their OWN
|
|
905
|
+
// invitation link, good for this call while it runs (`st.invited`). No contacts, no typed emails, no copied link.
|
|
906
|
+
let invites = null;
|
|
907
|
+
try { invites = await import('../../server/services/callInvites.js'); } catch { invites = null; }
|
|
908
|
+
const inviterOf = (room, identity) => (String(identity || '').startsWith(`${room}:`) ? String(identity).slice(room.length + 1) : '');
|
|
909
|
+
const invite = invites ? {
|
|
910
|
+
search: async (room, q, {identity}) => {
|
|
911
|
+
const uid = inviterOf(room, identity);
|
|
912
|
+
const entry = uid && live.has(room) ? await getThreadForUser(room, uid) : null;
|
|
913
|
+
if (!entry) return [];
|
|
914
|
+
const reach = new Set((await reachableFor(await getUserById(uid).catch(() => null))).map(u => u.id));
|
|
915
|
+
return invites.searchInvitees({hostUserId: uid, q, contacts: false, excludeUserIds: entry.data.participants ?? [], teamFilter: (u) => reach.has(u.id)});
|
|
916
|
+
},
|
|
917
|
+
send: async (room, people, {identity, name, via, request}) => {
|
|
918
|
+
const uid = inviterOf(room, identity);
|
|
919
|
+
const entry = uid ? await getThreadForUser(room, uid) : null;
|
|
920
|
+
const st = live.get(room);
|
|
921
|
+
if (!entry || !st) return {error: 'This call has ended.'};
|
|
922
|
+
if (!people.length) return {error: 'A call in Messages has no link to copy - add team members instead.'};
|
|
923
|
+
const inConversation = new Set(entry.data.participants ?? []);
|
|
924
|
+
const reach = new Set((await reachableFor(await getUserById(uid).catch(() => null))).map(u => u.id));
|
|
925
|
+
const targets = await invites.resolveInvitees(people, {hostUserId: uid, contacts: false, emails: false,
|
|
926
|
+
teamFilter: (u) => reach.has(u.id) && !inConversation.has(u.id)});
|
|
927
|
+
for (const t of targets) if (t.kind === 'user') st.invited.add(t.id);
|
|
928
|
+
const base = invites.inviteOrigin ? invites.inviteOrigin(request) : siteOrigin(request);
|
|
929
|
+
const results = await invites.deliverInvites(targets, {linkFor: (t) => `${base}${inviteLink(callSecret(), room, t.id)}`,
|
|
930
|
+
hostName: name || await nameOf(uid), hostUserId: uid, title: entry.data.kind === 'dm' ? '' : String(entry.data.name || ''),
|
|
931
|
+
via, key: `messages:${room}`, log: fastify.log, expiresAt: new Date(Date.now() + 2 * 3600e3).toISOString()});
|
|
932
|
+
return {results};
|
|
933
|
+
}
|
|
934
|
+
} : null;
|
|
935
|
+
calls = mountCallRoutes(fastify, {prefix: '/call', ns: 'messages', chat: callChat, ...(invite ? {invite} : {}), resolve: async (request, room, body) => {
|
|
936
|
+
const invited = String(body?.i ?? '') === '1';
|
|
937
|
+
const userId = invited ? checkInvite(callSecret(), room, body) : checkJoin(callSecret(), room, body);
|
|
938
|
+
if (!userId) return null;
|
|
939
|
+
const entry = await getEntry(THREADS_SLUG, room).catch(() => null);
|
|
940
|
+
if (!entry) return null;
|
|
941
|
+
const thread = {id: entry.id, ...entry.data};
|
|
942
|
+
// An invitation lets its person into THIS call only, while it runs.
|
|
943
|
+
if (invited && !live.get(entry.id)?.invited?.has(userId)) return null;
|
|
944
|
+
const user = await getUserById(userId).catch(() => null);
|
|
945
|
+
const names = {};
|
|
946
|
+
if (thread.kind === 'dm') for (const id of thread.participants ?? []) names[id] = await nameOf(id);
|
|
947
|
+
const who = whoIsCalling(thread, user, userId, names, {invited});
|
|
948
|
+
if (!who) return null;
|
|
949
|
+
// Someone rung in from outside the conversation sees the call's chat only from when they joined - never
|
|
950
|
+
// the conversation's earlier call messages (the engine's `history: false`).
|
|
951
|
+
if (invited && !(thread.participants ?? []).includes(userId)) who.history = false;
|
|
952
|
+
const st = live.get(entry.id) || await startCall(entry, userId);
|
|
953
|
+
if (!st.joinedAt) st.joinedAt = Date.now();
|
|
954
|
+
return who;
|
|
955
|
+
}});
|
|
956
|
+
const sweeper = setInterval(() => { sweepCalls().catch(() => {}); }, options.callSweepMs || 5000);
|
|
957
|
+
sweeper.unref?.();
|
|
958
|
+
fastify.addHook('onClose', async () => clearInterval(sweeper));
|
|
959
|
+
} catch (err) {
|
|
960
|
+
fastify.log?.warn?.(`[messages] calls unavailable on this CMS: ${err?.message || err}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Direct messages for core (1.7; hooks.js 7c): a call's "Add people" in any Tool sends its Join link as a direct
|
|
964
|
+
// message from the host to a team member - the pair's DM (made when there is none), posted as the host.
|
|
965
|
+
if (typeof hooks.registerDirectMessenger === 'function') {
|
|
966
|
+
try { hooks.registerDirectMessenger((msg) => sendDirect(msg)); } catch { /* older engine */ }
|
|
967
|
+
}
|
|
968
|
+
async function sendDirect({fromUserId, toUserId, text}) {
|
|
969
|
+
const body = String(text || '').trim().slice(0, 4000);
|
|
970
|
+
if (!fromUserId || !toUserId || fromUserId === toUserId || !body) return {sent: false, reason: 'nobody to send it to'};
|
|
971
|
+
const [from, to] = await Promise.all([getUserById(fromUserId).catch(() => null), getUserById(toUserId).catch(() => null)]);
|
|
972
|
+
const active = (u) => u && u.disabled !== true && u.active !== false && u.isActive !== false;
|
|
973
|
+
if (!active(from) || !active(to)) return {sent: false, reason: 'the account is switched off'};
|
|
974
|
+
// Messages' own rule for who may message whom (the same as the picker and POST /threads).
|
|
975
|
+
if (!(await reachableFor(from)).some(u => u.id === to.id)) return {sent: false, reason: 'you cannot message them in Messages'};
|
|
976
|
+
const pair = [String(fromUserId), String(toUserId)].sort();
|
|
977
|
+
const {entries} = await listEntries(THREADS_SLUG, {limit: 10000});
|
|
978
|
+
let thread = entries.find(e => e.data.kind === 'dm' && Array.isArray(e.data.participants) && [...e.data.participants].sort().join('|') === pair.join('|'));
|
|
979
|
+
const now = new Date().toISOString();
|
|
980
|
+
if (!thread) {
|
|
981
|
+
thread = await createEntry(THREADS_SLUG, {kind: 'dm', name: null, participants: pair, createdBy: fromUserId, retentionDays: null,
|
|
982
|
+
lastMessageAt: now, lastMessagePreview: ''}, {createdBy: fromUserId, source: 'messages'});
|
|
983
|
+
broadcast([toUserId], 'thread:created', toThread(thread));
|
|
984
|
+
}
|
|
985
|
+
const msgEntry = await createEntry(MESSAGES_SLUG, {threadId: thread.id, senderId: fromUserId, body, attachments: [], createdAt: now,
|
|
986
|
+
editedAt: null, deletedAt: null, readBy: [fromUserId]}, {createdBy: fromUserId, source: 'messages'});
|
|
987
|
+
await touchThread(thread.id, {archivedBy: [], lastMessageAt: now, lastMessagePreview: body.slice(0, 120)});
|
|
988
|
+
broadcast(pair, 'message:new', {threadId: thread.id, message: toMessage(msgEntry)});
|
|
989
|
+
return {sent: true};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// For the tests: the sweep, run now, and what is running.
|
|
993
|
+
options.onCalls?.({sweep: (now) => sweepCalls(now), live, rooms: calls, present, sendDirect});
|
|
994
|
+
}
|