@xmanrui/dsh-im 4.24.1 → 4.26.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.
Files changed (49) hide show
  1. package/PROACTIVE_DELIVERY.en.md +12 -4
  2. package/PROACTIVE_DELIVERY.md +12 -4
  3. package/README.en.md +11 -3
  4. package/README.md +11 -3
  5. package/THIRD_PARTY_NOTICES.md +2 -0
  6. package/lib/client.js +654 -311
  7. package/lib/index.js +298 -289
  8. package/package.json +7 -8
  9. package/plugin-src/client/channel-logos.js +11 -0
  10. package/plugin-src/client/channels/matrix/api.js +11 -0
  11. package/plugin-src/client/channels/matrix/index.js +151 -0
  12. package/plugin-src/client/channels/matrix/styles.js +36 -0
  13. package/plugin-src/client/global-settings.js +26 -14
  14. package/plugin-src/client/i18n.js +20 -0
  15. package/plugin-src/client/index.js +20 -0
  16. package/plugin-src/client/session-channel-logos.js +2 -1
  17. package/plugin-src/client/styles.js +19 -3
  18. package/plugin-src/client/update-panel.js +31 -11
  19. package/plugin-src/host/channels/matrix/index.mjs +31 -0
  20. package/plugin-src/host/channels/matrix/production.mjs +227 -0
  21. package/plugin-src/host/channels/matrix/rpc.mjs +228 -0
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/startup-error.mjs +2 -1
  24. package/plugin-src/host/delivery-adapter.mjs +18 -0
  25. package/plugin-src/host/delivery-rpc.mjs +7 -2
  26. package/plugin-src/host/delivery-service.mjs +8 -2
  27. package/plugin-src/host/delivery-suggestions.mjs +13 -0
  28. package/plugin-src/host/index.mjs +3 -0
  29. package/scripts/verify-injected-context.mjs +120 -0
  30. package/scripts/verify-lan-management.mjs +1 -1
  31. package/scripts/verify-package.mjs +6 -1
  32. package/src/channels/feishu/feishu-runtime.mjs +13 -3
  33. package/src/channels/matrix/matrix-api.mjs +696 -0
  34. package/src/channels/matrix/matrix-bridge.mjs +20 -0
  35. package/src/channels/matrix/matrix-config-store.mjs +356 -0
  36. package/src/channels/matrix/matrix-controller.mjs +404 -0
  37. package/src/channels/matrix/matrix-crypto-store.mjs +279 -0
  38. package/src/channels/matrix/matrix-crypto.mjs +1014 -0
  39. package/src/channels/matrix/matrix-harness-client.mjs +11 -0
  40. package/src/channels/matrix/matrix-normalize.mjs +357 -0
  41. package/src/channels/matrix/matrix-rich-text.mjs +313 -0
  42. package/src/channels/matrix/matrix-runtime.mjs +900 -0
  43. package/src/channels/shared/command-catalog.mjs +1 -1
  44. package/src/channels/shared/i18n-en/image-input.mjs +1 -0
  45. package/src/channels/shared/i18n-en/matrix.mjs +75 -0
  46. package/src/channels/shared/i18n-en.mjs +2 -0
  47. package/src/channels/shared/injected-context.mjs +3 -3
  48. package/src/channels/shared/session-channel-labels.mjs +1 -0
  49. package/src/channels/shared/text-harness-bridge.mjs +3 -0
@@ -0,0 +1,404 @@
1
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
2
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
3
+ import { t } from '../shared/i18n.mjs';
4
+ import { deriveMatrixBotIdentity, maskMatrixBotId } from './matrix-config-store.mjs';
5
+ import { inspectMatrixCredentials, isMatrixUserId, validateMatrixHomeserver } from './matrix-api.mjs';
6
+ import { MATRIX_DESCRIPTOR } from './matrix-bridge.mjs';
7
+
8
+ function cleanString(value) {
9
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
10
+ }
11
+
12
+ function safeError(code, message) {
13
+ return Object.freeze({ code, message });
14
+ }
15
+
16
+ export class MatrixController {
17
+ #credentials;
18
+ #configStore;
19
+ #inspectCredentials;
20
+ #createRuntime;
21
+ #deleteState;
22
+ #logger;
23
+ #runtimes = new Map();
24
+ #errors = new Map();
25
+ #transitions = new Map();
26
+ #revision = 0;
27
+ #closed = false;
28
+
29
+ constructor({
30
+ credentials,
31
+ configStore,
32
+ inspectCredentials = inspectMatrixCredentials,
33
+ createRuntime,
34
+ deleteState = async () => {},
35
+ logger = console,
36
+ }) {
37
+ if (!credentials || typeof credentials.resolve !== 'function'
38
+ || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
39
+ throw new TypeError('Matrix requires the DSH credential provider');
40
+ }
41
+ if (!configStore || typeof configStore.list !== 'function'
42
+ || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
43
+ throw new TypeError('Matrix requires a config store');
44
+ }
45
+ if (typeof inspectCredentials !== 'function' || typeof createRuntime !== 'function') {
46
+ throw new TypeError('Matrix controller dependencies are incomplete');
47
+ }
48
+ this.#credentials = credentials;
49
+ this.#configStore = configStore;
50
+ this.#inspectCredentials = inspectCredentials;
51
+ this.#createRuntime = createRuntime;
52
+ this.#deleteState = deleteState;
53
+ this.#logger = logger;
54
+ }
55
+
56
+ async initialize() {
57
+ if (this.#closed) return this.status();
58
+ for (const config of this.#configStore.list()) {
59
+ await this.#withBotTransition(config.botId, async () => {
60
+ if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
61
+ const resolved = await this.#resolveCredentials(config);
62
+ if (!resolved) {
63
+ this.#errors.set(config.botId, safeError(
64
+ 'missing-token',
65
+ t('Matrix机器人凭据缺失,请移除后重新接入。'),
66
+ ));
67
+ return;
68
+ }
69
+ try {
70
+ await this.#startRuntime(config, resolved);
71
+ this.#errors.delete(config.botId);
72
+ } catch (error) {
73
+ this.#errors.set(config.botId, safeError(
74
+ 'connection-failed',
75
+ error?.message ?? t('Matrix 连接未就绪,插件会自动重试。'),
76
+ ));
77
+ this.#logger.warn?.(
78
+ `[dsh-im:matrix] bot ${config.botId} failed to initialize:`,
79
+ error,
80
+ );
81
+ } finally {
82
+ this.#touch();
83
+ }
84
+ });
85
+ }
86
+ return this.status();
87
+ }
88
+
89
+ async bindCredentials({ homeserver, accessToken, userId, password } = {}) {
90
+ if (this.#closed) throw new Error('Matrix controller is closed');
91
+ const base = validateMatrixHomeserver(homeserver);
92
+ const token = cleanString(accessToken);
93
+ const identifier = cleanString(userId);
94
+ const secret = cleanString(password);
95
+ if (!base) {
96
+ const error = new Error(t('Matrix homeserver 地址无效,请填写 https:// 或 http:// 开头的完整地址。'));
97
+ error.code = 'invalid-config';
98
+ throw error;
99
+ }
100
+ if (!token && !(identifier && secret)) {
101
+ const error = new Error(t('Matrix 凭据不完整:请提供访问令牌,或用户 ID 与密码的组合。'));
102
+ error.code = 'invalid-config';
103
+ throw error;
104
+ }
105
+ if (identifier && !isMatrixUserId(identifier)) {
106
+ const error = new Error(t('Matrix 用户 ID 无效,请使用 @user:server 形式。'));
107
+ error.code = 'invalid-config';
108
+ throw error;
109
+ }
110
+ const inspected = await this.#inspectCredentials({
111
+ homeserver: base,
112
+ ...(token ? { accessToken: token } : {}),
113
+ ...(identifier ? { userId: identifier } : {}),
114
+ ...(secret ? { password: secret } : {}),
115
+ });
116
+ const platformId = cleanString(inspected?.platformId);
117
+ const resolvedUserId = cleanString(inspected?.userId);
118
+ const name = cleanString(inspected?.name);
119
+ if (!platformId || !resolvedUserId || !name) {
120
+ throw new Error('Matrix homeserver returned an incomplete bot identity');
121
+ }
122
+ const derived = deriveMatrixBotIdentity({ homeserver: base, userId: resolvedUserId });
123
+ const existing = this.#configStore.getByPlatformId(platformId);
124
+ if (existing && existing.botId !== derived.botId) {
125
+ throw new Error('This Matrix bot belongs to another Harness installation');
126
+ }
127
+ const previous = this.#configStore.get(derived.botId);
128
+ const previousRefs = await Promise.all([
129
+ this.#credentials.resolve(derived.tokenRef).catch(() => undefined),
130
+ this.#credentials.resolve(derived.passwordRef).catch(() => undefined),
131
+ ]);
132
+ try {
133
+ if (token) await this.#credentials.set(derived.tokenRef, token);
134
+ else await this.#credentials.unset(derived.tokenRef);
135
+ if (secret) await this.#credentials.set(derived.passwordRef, secret);
136
+ else await this.#credentials.unset(derived.passwordRef);
137
+ await this.#configStore.save({
138
+ botId: derived.botId,
139
+ platformId,
140
+ homeserver: base,
141
+ userId: resolvedUserId,
142
+ ...(cleanString(inspected?.deviceId) ? { deviceId: cleanString(inspected.deviceId) } : {}),
143
+ tokenRef: derived.tokenRef,
144
+ passwordRef: derived.passwordRef,
145
+ name,
146
+ username: cleanString(inspected?.username),
147
+ createdAt: previous?.createdAt ?? new Date().toISOString(),
148
+ connectedAt: previous?.connectedAt ?? null,
149
+ });
150
+ } catch (error) {
151
+ await Promise.all([
152
+ this.#restoreCredential(derived.tokenRef, previousRefs[0]),
153
+ this.#restoreCredential(derived.passwordRef, previousRefs[1]),
154
+ ]).catch(() => undefined);
155
+ if (!previous) await this.#configStore.remove(derived.botId).catch(() => undefined);
156
+ throw error;
157
+ }
158
+ await this.#withBotTransition(derived.botId, async () => {
159
+ await this.#stopRuntime(derived.botId);
160
+ const config = this.#configStore.get(derived.botId);
161
+ if (!config) return;
162
+ const resolved = await this.#resolveCredentials(config);
163
+ if (!resolved) return;
164
+ await this.#startRuntime(config, resolved);
165
+ this.#errors.delete(derived.botId);
166
+ });
167
+ this.#touch();
168
+ return this.#publicBot(this.#configStore.get(derived.botId));
169
+ }
170
+
171
+ async reconnectBot(botId, { sendTest = false } = {}) {
172
+ this.#requireId(botId);
173
+ if (this.#closed) return this.status();
174
+ return await this.#withBotTransition(botId, async () => {
175
+ await this.#stopRuntime(botId);
176
+ const config = this.#configStore.get(botId);
177
+ if (!config) return this.status();
178
+ const resolved = await this.#resolveCredentials(config);
179
+ if (!resolved) {
180
+ this.#errors.set(botId, safeError(
181
+ 'missing-token',
182
+ t('Matrix机器人凭据缺失,请重新输入凭据。'),
183
+ ));
184
+ this.#touch();
185
+ return this.status();
186
+ }
187
+ try {
188
+ await this.#startRuntime(config, resolved);
189
+ this.#errors.delete(botId);
190
+ const saved = this.#configStore.get(botId);
191
+ if (saved) {
192
+ await this.#configStore.save({ ...saved, connectedAt: new Date().toISOString() });
193
+ }
194
+ if (sendTest) await this.sendConnectionTest(botId);
195
+ } catch (error) {
196
+ this.#errors.set(botId, safeError('connection-failed', error?.message ?? String(error)));
197
+ } finally {
198
+ this.#touch();
199
+ }
200
+ return this.status();
201
+ });
202
+ }
203
+
204
+ async sendConnectionTest(botId) {
205
+ this.#requireId(botId);
206
+ const runtime = this.#runtimes.get(botId);
207
+ if (!runtime?.status?.ready) throw new Error('Matrix bot is not connected');
208
+ const config = this.#configStore.get(botId);
209
+ const masked = maskMatrixBotId(config?.platformId ?? botId);
210
+ const name = cleanString(runtime.status?.name) ?? cleanString(config?.name) ?? masked;
211
+ if (typeof runtime.sendConnectionTest !== 'function') {
212
+ throw new Error('Matrix connection test is unavailable');
213
+ }
214
+ return await runtime.sendConnectionTest(connectionTestMessage(`${name}(${masked})`, t('Matrix机器人')));
215
+ }
216
+
217
+ async sendProactiveText(botId, target, text, options = {}) {
218
+ this.#requireId(botId);
219
+ const runtime = this.#runtimes.get(botId);
220
+ if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
221
+ throw new Error('Matrix bot is not connected for proactive delivery');
222
+ }
223
+ await runtime.sendProactiveText(target, String(text ?? ''), options);
224
+ return { sent: true };
225
+ }
226
+
227
+ async deleteBot(botId) {
228
+ this.#requireId(botId);
229
+ const removed = await this.#withBotTransition(botId, async () => {
230
+ const config = this.#configStore.get(botId);
231
+ await this.#stopRuntime(botId);
232
+ await this.#deleteState({ botId, config: config ?? {} }).catch(() => undefined);
233
+ await Promise.all([
234
+ this.#credentials.unset(`DSH_MATRIX_TOKEN_${botId.slice(7).toUpperCase()}`),
235
+ this.#credentials.unset(`DSH_MATRIX_PASSWORD_${botId.slice(7).toUpperCase()}`),
236
+ ]).catch(() => undefined);
237
+ return this.#configStore.remove(botId);
238
+ });
239
+ this.#errors.delete(botId);
240
+ this.#touch();
241
+ return removed;
242
+ }
243
+
244
+ status() {
245
+ const bots = this.#configStore.list().map((config) => {
246
+ const runtime = this.#runtimes.get(config.botId);
247
+ const state = runtime?.status ?? null;
248
+ // The shared token-channel settings view (createTokenChannelApi) renders
249
+ // online/offline, the summary line and "最近检查" from these fields. Emit
250
+ // them here so a connected bot is reported as connected instead of falling
251
+ // back to the offline defaults, matching the other token channels.
252
+ const hasError = Boolean(this.#errors.get(config.botId));
253
+ const connected = state?.ready === true
254
+ && state?.connectionState === 'connected'
255
+ && state?.harnessReachable !== false;
256
+ const botState = connected ? 'connected'
257
+ : state?.connectionState === 'connecting' ? 'connecting'
258
+ : hasError || state?.connectionState === 'failed' ? 'error' : 'offline';
259
+ return {
260
+ botId: config.botId,
261
+ name: maskMatrixBotId(config.platformId),
262
+ bot: {
263
+ name: cleanString(config.name) ?? maskMatrixBotId(config.platformId),
264
+ username: cleanString(config.username) ?? undefined,
265
+ idMasked: maskMatrixBotId(config.platformId),
266
+ },
267
+ idMasked: maskMatrixBotId(config.platformId),
268
+ ready: Boolean(state?.ready),
269
+ connectionState: state?.connectionState ?? (hasError ? 'failed' : 'idle'),
270
+ harnessReachable: state ? state.harnessReachable !== false : false,
271
+ connected,
272
+ state: botState,
273
+ configured: true,
274
+ health: {
275
+ status: connected ? 'healthy' : botState === 'error' ? 'error' : 'offline',
276
+ summary: connected
277
+ ? t('Matrix 长轮询接收和 Harness 回复全部正常。')
278
+ : hasError || botState === 'error'
279
+ ? t('Matrix 连接未就绪,插件会自动重试。')
280
+ : t('Matrix 连接当前离线。'),
281
+ lastCheckedAt: state?.lastCheckedAt ?? state?.lastConnectedAt ?? null,
282
+ lastConnectedAt: state?.lastConnectedAt ?? null,
283
+ },
284
+ stats: {
285
+ messagesReceived: state?.messagesReceived ?? 0,
286
+ messagesReplied: state?.messagesReplied ?? 0,
287
+ },
288
+ lastConnectedAt: state?.lastConnectedAt ?? null,
289
+ lastError: this.#errors.get(config.botId) ?? state?.lastError ?? null,
290
+ lastMessageAt: state?.lastMessageAt ?? null,
291
+ lastMessageError: publicMessageFailure(state?.lastMessageError) ?? null,
292
+ };
293
+ });
294
+ const ready = bots.filter((bot) => bot.ready).length;
295
+ return {
296
+ revision: this.#revision,
297
+ ready: this.#runtimes.size > 0 && ready === this.#runtimes.size,
298
+ totals: {
299
+ bots: bots.length,
300
+ ready,
301
+ failed: bots.filter((bot) => bot.connectionState === 'failed').length,
302
+ },
303
+ summary: {
304
+ healthy: this.#runtimes.size > 0 && ready === this.#runtimes.size
305
+ ? t('Matrix 长轮询接收和 Harness 回复全部正常。')
306
+ : ready === 0 && bots.length > 0
307
+ ? t('Matrix 长轮询尚未建立,请检查 homeserver 与凭据。')
308
+ : t('Matrix 正在处理消息;当前存在未恢复的连接。'),
309
+ attention: bots.flatMap((bot) => bot.lastMessageError
310
+ ? [`${bot.name}:${bot.lastMessageError.message}`] : []),
311
+ },
312
+ bots,
313
+ };
314
+ }
315
+
316
+ async close() {
317
+ if (this.#closed) return;
318
+ this.#closed = true;
319
+ await Promise.all([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
320
+ this.#runtimes.clear();
321
+ this.#transitions.clear();
322
+ this.#touch();
323
+ }
324
+
325
+ async #startRuntime(config, resolved) {
326
+ await this.#stopRuntime(config.botId);
327
+ const runtime = await this.#createRuntime({
328
+ botId: config.botId,
329
+ config,
330
+ homeserver: config.homeserver,
331
+ userId: config.userId,
332
+ ...resolved,
333
+ });
334
+ this.#runtimes.set(config.botId, runtime);
335
+ try {
336
+ await runtime.start();
337
+ } catch (error) {
338
+ await this.#stopRuntime(config.botId);
339
+ throw error;
340
+ }
341
+ if (!runtime.status?.ready) {
342
+ await this.#stopRuntime(config.botId);
343
+ throw new Error('Matrix connection is not ready');
344
+ }
345
+ return runtime;
346
+ }
347
+
348
+ async #stopRuntime(botId) {
349
+ const previous = this.#runtimes.get(botId);
350
+ this.#runtimes.delete(botId);
351
+ try {
352
+ await previous?.stop?.();
353
+ } catch (error) {
354
+ this.#logger.warn?.(`[dsh-im:matrix] bot ${botId} stopped with an error:`, error);
355
+ }
356
+ }
357
+
358
+ async #resolveCredentials(config) {
359
+ const token = cleanString((await this.#credentials.resolve(config.tokenRef).catch(() => undefined))?.value);
360
+ if (token) return { accessToken: token };
361
+ const password = cleanString((await this.#credentials.resolve(config.passwordRef).catch(() => undefined))?.value);
362
+ if (password && config.userId) return { password, userId: config.userId };
363
+ return null;
364
+ }
365
+
366
+ async #restoreCredential(ref, previous) {
367
+ if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
368
+ else await this.#credentials.unset(ref).catch(() => undefined);
369
+ }
370
+
371
+ #requireId(botId) {
372
+ if (typeof botId !== 'string' || !/^matrix_[a-f0-9]{24}$/.test(botId)) {
373
+ throw new TypeError('Invalid Matrix bot id');
374
+ }
375
+ }
376
+
377
+ #publicBot(config) {
378
+ if (!config) throw new Error('Matrix bot is no longer configured');
379
+ return {
380
+ botId: config.botId,
381
+ name: maskMatrixBotId(config.platformId),
382
+ bot: {
383
+ name: cleanString(config.name) ?? maskMatrixBotId(config.platformId),
384
+ username: cleanString(config.username) ?? undefined,
385
+ idMasked: maskMatrixBotId(config.platformId),
386
+ },
387
+ idMasked: maskMatrixBotId(config.platformId),
388
+ };
389
+ }
390
+
391
+ #withBotTransition(botId, operation) {
392
+ const previous = this.#transitions.get(botId) ?? Promise.resolve();
393
+ const current = previous.catch(() => undefined).then(operation);
394
+ const settled = current.finally(() => {
395
+ if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
396
+ });
397
+ this.#transitions.set(botId, settled);
398
+ return settled;
399
+ }
400
+
401
+ #touch() {
402
+ this.#revision += 1;
403
+ }
404
+ }
@@ -0,0 +1,279 @@
1
+ // Persistent device-local crypto state for the Matrix channel. Pickles are opaque
2
+ // libolm blobs; the pickling passphrase lives beside them so a stolen file is the
3
+ // same trust boundary as the on-disk access token (documented limitation, see
4
+ // docs/方案/Matrix端到端加密可行性调研.md §4). All writes are serialized through one
5
+ // queue and land atomically with restrictive permissions.
6
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
7
+ import { dirname } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
9
+
10
+ const CRYPTO_FILE = 'matrix-crypto.json';
11
+ const MAX_SESSIONS = 1_000;
12
+ const MAX_INBOUND_GROUP_SESSIONS = 1_000;
13
+ const MAX_PENDING_ROOM_KEYS = 200;
14
+ const MAX_REQUEST_STATE = 200;
15
+
16
+ function cleanText(value) {
17
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
18
+ }
19
+
20
+ function pickledEntry(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
22
+ const pickle = cleanText(value.pickle);
23
+ if (!pickle) return null;
24
+ const createdAt = Number(value.createdAt);
25
+ const lastUsedAt = Number(value.lastUsedAt);
26
+ return {
27
+ pickle,
28
+ createdAt: Number.isSafeInteger(createdAt) && createdAt > 0 ? createdAt : 0,
29
+ lastUsedAt: Number.isSafeInteger(lastUsedAt) && lastUsedAt > 0 ? lastUsedAt : 0,
30
+ };
31
+ }
32
+
33
+ function sessionEntries(value, requiredFields) {
34
+ if (!Array.isArray(value)) return [];
35
+ const entries = [];
36
+ for (const candidate of value) {
37
+ const base = pickledEntry(candidate);
38
+ if (!base) continue;
39
+ let ok = true;
40
+ for (const field of requiredFields) {
41
+ if (!cleanText(candidate?.[field])) {
42
+ ok = false;
43
+ break;
44
+ }
45
+ }
46
+ if (!ok) continue;
47
+ const entry = { ...base };
48
+ for (const field of requiredFields) entry[field] = cleanText(candidate[field]);
49
+ entries.push(Object.freeze(entry));
50
+ }
51
+ return Object.freeze(entries);
52
+ }
53
+
54
+ function outboundGroups(value) {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
56
+ const result = {};
57
+ for (const [roomId, candidate] of Object.entries(value)) {
58
+ const base = pickledEntry(candidate);
59
+ const sessionId = cleanText(candidate?.sessionId);
60
+ if (!base || !sessionId || !cleanText(candidate?.sharedOwnerKey)) continue;
61
+ result[roomId] = Object.freeze({
62
+ ...base,
63
+ sessionId,
64
+ sharedOwnerKey: cleanText(candidate.sharedOwnerKey),
65
+ messageIndex: Number.isSafeInteger(Number(candidate.messageIndex)) && Number(candidate.messageIndex) >= 0
66
+ ? Number(candidate.messageIndex) : 0,
67
+ sharedWith: Object.freeze(Array.isArray(candidate.sharedWith)
68
+ ? [...new Set(candidate.sharedWith.filter((entry) => typeof entry === 'string' && entry.trim()))]
69
+ : []),
70
+ });
71
+ }
72
+ return result;
73
+ }
74
+
75
+ function requestState(value) {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
77
+ const result = {};
78
+ let kept = 0;
79
+ for (const [key, candidate] of Object.entries(value)) {
80
+ if (kept >= MAX_REQUEST_STATE) break;
81
+ const at = Number(candidate?.at);
82
+ if (!cleanText(key) || !Number.isSafeInteger(at) || at <= 0) continue;
83
+ result[key] = Object.freeze({
84
+ at,
85
+ tries: Number.isSafeInteger(Number(candidate.tries)) && Number(candidate.tries) > 0
86
+ ? Number(candidate.tries) : 1,
87
+ });
88
+ kept += 1;
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function pendingRoomKeys(value) {
94
+ if (!Array.isArray(value)) return [];
95
+ const entries = [];
96
+ for (const candidate of value) {
97
+ const roomId = cleanText(candidate?.roomId);
98
+ const sessionId = cleanText(candidate?.sessionId);
99
+ const senderKey = cleanText(candidate?.senderKey);
100
+ const exportedSessionKey = cleanText(candidate?.exportedSessionKey);
101
+ if (!roomId || !sessionId || !senderKey || !exportedSessionKey) continue;
102
+ const createdAt = Number(candidate.createdAt);
103
+ entries.push(Object.freeze({
104
+ roomId,
105
+ sessionId,
106
+ senderKey,
107
+ exportedSessionKey,
108
+ createdAt: Number.isSafeInteger(createdAt) && createdAt > 0 ? createdAt : 0,
109
+ tries: Number.isSafeInteger(Number(candidate.tries)) && Number(candidate.tries) > 0
110
+ ? Number(candidate.tries) : 0,
111
+ }));
112
+ if (entries.length >= MAX_PENDING_ROOM_KEYS) break;
113
+ }
114
+ return Object.freeze(entries);
115
+ }
116
+
117
+ function deviceKeySnapshot(value) {
118
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
119
+ const signaturePayload = cleanText(value.signaturePayload);
120
+ if (!signaturePayload) return null;
121
+ const uploadedAt = Number(value.uploadedAt);
122
+ const oneTimeKeyCount = Number(value.oneTimeKeyCount);
123
+ return Object.freeze({
124
+ signaturePayload,
125
+ uploadedAt: Number.isSafeInteger(uploadedAt) && uploadedAt > 0 ? uploadedAt : 0,
126
+ oneTimeKeyCount: Number.isSafeInteger(oneTimeKeyCount) && oneTimeKeyCount >= 0 ? oneTimeKeyCount : 0,
127
+ });
128
+ }
129
+
130
+ function normalizeDocument(parsed, { requireIdentity = false } = {}) {
131
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
132
+ if (parsed.version !== 1) return null;
133
+ const picklingPassphrase = cleanText(parsed.picklingPassphrase);
134
+ const deviceId = cleanText(parsed.deviceId);
135
+ const accountPickle = cleanText(parsed.accountPickle);
136
+ const curveKey = cleanText(parsed.accountIdentities?.curve25519);
137
+ const edKey = cleanText(parsed.accountIdentities?.ed25519);
138
+ const pkDecryptionPickle = cleanText(parsed.pkDecryptionPickle);
139
+ const pkEncryptionKey = cleanText(parsed.pkEncryptionKey);
140
+ if (!picklingPassphrase || !deviceId || !accountPickle || !pkDecryptionPickle || !pkEncryptionKey) return null;
141
+ if (requireIdentity && (!curveKey || !edKey)) return null;
142
+ const watermark = Number(parsed.oneTimeKeyWatermark);
143
+ return {
144
+ version: 1,
145
+ picklingPassphrase,
146
+ deviceId,
147
+ accountPickle,
148
+ accountIdentities: Object.freeze({
149
+ curve25519: curveKey ?? '',
150
+ ed25519: edKey ?? '',
151
+ }),
152
+ pkDecryptionPickle,
153
+ pkEncryptionKey,
154
+ oneTimeKeyWatermark: Number.isSafeInteger(watermark) && watermark >= 0 ? watermark : 0,
155
+ sessions: sessionEntries(parsed.sessions, ['sessionId', 'senderUserId', 'senderDevice']),
156
+ groupInbound: sessionEntries(parsed.groupInbound, ['roomId', 'senderKey', 'sessionId']),
157
+ groupOutbound: outboundGroups(parsed.groupOutbound),
158
+ pendingRoomKeys: pendingRoomKeys(parsed.pendingRoomKeys),
159
+ requestState: requestState(parsed.requestState),
160
+ uploadedKeys: deviceKeySnapshot(parsed.uploadedKeys),
161
+ };
162
+ }
163
+
164
+ export function matrixCryptoPathFor(directory) {
165
+ const clean = cleanText(directory);
166
+ if (!clean) throw new TypeError('Matrix crypto persistence requires a bot directory');
167
+ return `${clean.replace(/[\\/]+$/, '')}/${CRYPTO_FILE}`;
168
+ }
169
+
170
+ export class MatrixCryptoStore {
171
+ #path;
172
+ #value = null;
173
+ #writeQueue = Promise.resolve();
174
+
175
+ constructor(path) {
176
+ const clean = cleanText(path);
177
+ if (!clean) throw new TypeError('Matrix crypto store requires a file path');
178
+ this.#path = clean;
179
+ }
180
+
181
+ get path() {
182
+ return this.#path;
183
+ }
184
+
185
+ async load() {
186
+ try {
187
+ const parsed = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
188
+ if (!parsed) throw new Error('dsh-im Matrix crypto store contains invalid data');
189
+ this.#value = parsed;
190
+ } catch (error) {
191
+ if (error?.code !== 'ENOENT') throw error;
192
+ this.#value = null;
193
+ }
194
+ return this;
195
+ }
196
+
197
+ get snapshot() {
198
+ return this.#value ? structuredClone(this.#value) : null;
199
+ }
200
+
201
+ get isReady() {
202
+ return this.#value !== null;
203
+ }
204
+
205
+ async bootstrap(initial) {
206
+ if (this.#value) return this.#value;
207
+ const candidate = normalizeDocument({
208
+ version: 1,
209
+ picklingPassphrase: cleanText(initial?.picklingPassphrase) ?? Buffer.from(randomBytes(32)).toString('hex'),
210
+ deviceId: initial?.deviceId ?? '',
211
+ accountPickle: initial?.accountPickle ?? '',
212
+ accountIdentities: initial?.accountIdentities ?? {},
213
+ pkDecryptionPickle: initial?.pkDecryptionPickle ?? '',
214
+ pkEncryptionKey: initial?.pkEncryptionKey ?? '',
215
+ oneTimeKeyWatermark: initial?.oneTimeKeyWatermark ?? 0,
216
+ sessions: [],
217
+ groupInbound: [],
218
+ groupOutbound: {},
219
+ pendingRoomKeys: [],
220
+ requestState: {},
221
+ uploadedKeys: null,
222
+ }, { requireIdentity: true });
223
+ if (!candidate) throw new Error('Refusing to persist an incomplete Matrix crypto device state');
224
+ await this.#persist(candidate);
225
+ return structuredClone(candidate);
226
+ }
227
+
228
+ async apply(patch) {
229
+ const current = this.#value;
230
+ if (!current) throw new Error('Matrix crypto store is not bootstrapped yet');
231
+ const next = {
232
+ ...current,
233
+ oneTimeKeyWatermark: patch.oneTimeKeyWatermark === undefined
234
+ ? current.oneTimeKeyWatermark
235
+ : (Number.isSafeInteger(Number(patch.oneTimeKeyWatermark)) && Number(patch.oneTimeKeyWatermark) >= 0
236
+ ? Number(patch.oneTimeKeyWatermark) : current.oneTimeKeyWatermark),
237
+ sessions: patch.sessions === undefined ? current.sessions : trim(sessionEntries(patch.sessions, ['sessionId', 'senderUserId', 'senderDevice']), MAX_SESSIONS),
238
+ groupInbound: patch.groupInbound === undefined ? current.groupInbound : trim(sessionEntries(patch.groupInbound, ['roomId', 'senderKey', 'sessionId']), MAX_INBOUND_GROUP_SESSIONS),
239
+ groupOutbound: patch.groupOutbound === undefined ? current.groupOutbound : outboundGroups(patch.groupOutbound),
240
+ pendingRoomKeys: patch.pendingRoomKeys === undefined ? current.pendingRoomKeys : trim(pendingRoomKeys(patch.pendingRoomKeys), MAX_PENDING_ROOM_KEYS),
241
+ requestState: patch.requestState === undefined ? current.requestState : requestState(patch.requestState),
242
+ uploadedKeys: patch.uploadedKeys === undefined ? current.uploadedKeys : deviceKeySnapshot(patch.uploadedKeys),
243
+ };
244
+ const normalized = normalizeDocument(next, { requireIdentity: true });
245
+ if (!normalized) throw new Error('Refusing to persist incomplete Matrix crypto device state');
246
+ await this.#persist(normalized);
247
+ return structuredClone(normalized);
248
+ }
249
+
250
+ async remove() {
251
+ const operation = this.#writeQueue.then(async () => {
252
+ try {
253
+ await unlink(this.#path);
254
+ } catch (error) {
255
+ if (error?.code !== 'ENOENT') throw error;
256
+ }
257
+ });
258
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
259
+ await operation;
260
+ }
261
+
262
+ async #persist(next) {
263
+ const operation = this.#writeQueue.then(async () => {
264
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
265
+ const temporary = `${this.#path}.tmp`;
266
+ await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, {
267
+ encoding: 'utf8', mode: 0o600,
268
+ });
269
+ await rename(temporary, this.#path);
270
+ this.#value = next;
271
+ });
272
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
273
+ await operation;
274
+ }
275
+ }
276
+
277
+ function trim(entries, limit) {
278
+ return entries.slice(Math.max(0, entries.length - limit));
279
+ }