@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,900 @@
1
+ import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { t } from '../shared/i18n.mjs';
3
+ import {
4
+ isMatrixEventId,
5
+ isMatrixRoomId,
6
+ MatrixApi,
7
+ MatrixApiError,
8
+ performMatrixPasswordLogin,
9
+ validateMatrixHomeserver,
10
+ } from './matrix-api.mjs';
11
+ import {
12
+ createMatrixBridgeStatus,
13
+ MATRIX_DESCRIPTOR,
14
+ MatrixHarnessBridge,
15
+ } from './matrix-bridge.mjs';
16
+ import { MatrixCryptoEngine } from './matrix-crypto.mjs';
17
+ import {
18
+ ClockSkewGuard,
19
+ compileIgnorePatterns,
20
+ EventDedupeRing,
21
+ normalizeMatrixDeliveryTarget,
22
+ normalizeMatrixTimelineEvent,
23
+ resolveBangMatrixCommand,
24
+ } from './matrix-normalize.mjs';
25
+ import {
26
+ applyMatrixRelations,
27
+ buildMatrixEditContent,
28
+ buildMatrixReactionContent,
29
+ buildMatrixTextContent,
30
+ extractOutboundMentions,
31
+ hasRoomMention,
32
+ } from './matrix-rich-text.mjs';
33
+
34
+ const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
35
+ const SYNC_LONG_POLL_MS = 30_000;
36
+ const SYNC_RETRY_DELAY_MS = 5_000;
37
+ const INVITE_JOIN_TIMEOUT_MS = 45_000;
38
+ const DEFAULT_MAX_MESSAGE_LENGTH = 16_000;
39
+ const DEFAULT_MAX_MEDIA_BYTES = 104_857_600;
40
+ const EDIT_STREAM_INTERVAL_MS = 350;
41
+ const DEAD_ROOM_MARKERS = Object.freeze(['no servers', 'room not found']);
42
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
43
+
44
+ function cleanString(value) {
45
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
46
+ }
47
+
48
+ function toRoomSet(values) {
49
+ const list = Array.isArray(values) ? values : (values instanceof Set ? [...values] : []);
50
+ return new Set(list.filter((value) => isMatrixRoomId(value)));
51
+ }
52
+
53
+ function toTextSet(values) {
54
+ const list = Array.isArray(values) ? values : (values instanceof Set ? [...values] : []);
55
+ return new Set(list.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim()));
56
+ }
57
+
58
+ function safeErrorInfo(error) {
59
+ if (!error) return null;
60
+ return Object.freeze({
61
+ at: new Date().toISOString(),
62
+ code: error instanceof MatrixApiError ? error.code : (error?.code ?? 'error'),
63
+ message: String(error?.message ?? error).slice(0, 400).replaceAll(/mxc:\/\/[^\s]+/g, 'mxc://…'),
64
+ });
65
+ }
66
+
67
+ function summarizeCryptoStats(stats) {
68
+ if (!stats || typeof stats !== 'object') return null;
69
+ return Object.freeze({
70
+ inboundSessions: Number(stats.inboundSessions) || 0,
71
+ outboundRooms: Number(stats.outboundRooms) || 0,
72
+ pendingEvents: Number(stats.pendingEvents) || 0,
73
+ undecryptable: Number(stats.undecryptable) || 0,
74
+ keyUploads: Number(stats.keyUploads) || 0,
75
+ roomKeySends: Number(stats.roomKeySends) || 0,
76
+ shareFailures: Number(stats.shareFailures) || 0,
77
+ });
78
+ }
79
+
80
+ function resolveE2eeMode(value) {
81
+ const raw = String(value ?? '').trim().toLowerCase();
82
+ if (raw === 'off' || raw === 'false' || raw === 'no') return 'off';
83
+ if (raw === 'required' || raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on') return 'required';
84
+ return 'optional';
85
+ }
86
+
87
+ function inviterAllowed(accessPolicy, inviter) {
88
+ if (!inviter) return false;
89
+ const settings = typeof accessPolicy?.getSettings === 'function' ? accessPolicy.getSettings() : null;
90
+ const candidate = inviter.trim().toLowerCase();
91
+ for (const scope of [settings?.direct, settings?.group]) {
92
+ if (!scope) continue;
93
+ if (scope.mode === 'open') return true;
94
+ const users = Array.isArray(scope.allowlist?.users) ? scope.allowlist.users : [];
95
+ if (users.some((entry) => String(entry?.id ?? '').trim().toLowerCase() === candidate)) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ function inviteSenderOf(inviteRoom) {
101
+ const events = Array.isArray(inviteRoom?.invite_state?.events) ? inviteRoom.invite_state.events : [];
102
+ for (const event of events) {
103
+ if (event?.type === 'm.room.member' && event?.content?.membership === 'invite'
104
+ && typeof event.sender === 'string') return event.sender;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function timelineEventsOf(joinRoom) {
110
+ const events = Array.isArray(joinRoom?.timeline?.events) ? joinRoom.timeline.events : [];
111
+ return events.filter((event) => event && typeof event === 'object' && event.state_key === undefined);
112
+ }
113
+
114
+ function initialStateEventsOf(joinRoom) {
115
+ const events = Array.isArray(joinRoom?.state?.events) ? joinRoom.state.events : [];
116
+ return events.filter((event) => event && typeof event === 'object');
117
+ }
118
+
119
+ export function createMatrixRuntimeStatus() {
120
+ return Object.assign(createMatrixBridgeStatus(), {
121
+ startedAt: null,
122
+ ready: false,
123
+ connectionState: 'idle',
124
+ harnessReachable: false,
125
+ lastCheckedAt: null,
126
+ lastConnectedAt: null,
127
+ lastError: null,
128
+ joinedRooms: 0,
129
+ e2eeMode: 'optional',
130
+ e2eeActive: false,
131
+ lastCryptoError: null,
132
+ cryptoStats: null,
133
+ encryptedRoomsSeen: 0,
134
+ lastClockSkewAt: null,
135
+ });
136
+ }
137
+
138
+ export class MatrixRuntime {
139
+ #config;
140
+ #auth;
141
+ #harness;
142
+ #state;
143
+ #sidecar;
144
+ #contextEnhancement;
145
+ #accessPolicy;
146
+ #status;
147
+ #logger;
148
+ #createApi;
149
+ #isKnownCommand;
150
+ #replyTimeoutMs;
151
+ #api = null;
152
+ #bridge = null;
153
+ #botUserId = null;
154
+ #deviceId = null;
155
+ #crypto = null;
156
+ #cryptoStore;
157
+ #createCrypto;
158
+ #generation = 0;
159
+ #started = false;
160
+ #stopped = true;
161
+ #syncTask = null;
162
+ #reconnectTimer = null;
163
+ #reconnectIndex = 0;
164
+ #inviteTasks = new Map();
165
+ #clock = new ClockSkewGuard({});
166
+ #ring = new EventDedupeRing(1_000);
167
+ #patterns = [];
168
+ #dmRooms = new Set();
169
+ #joinedRooms = new Set();
170
+ #encryptedRooms = new Set();
171
+ #notifiedEncryptedRooms = new Set();
172
+ #e2eeActive = false;
173
+
174
+ constructor({
175
+ config = {},
176
+ accessToken,
177
+ password,
178
+ harness,
179
+ state,
180
+ sidecar,
181
+ contextEnhancement,
182
+ accessPolicy,
183
+ status = createMatrixRuntimeStatus(),
184
+ logger = console,
185
+ replyTimeoutMs = 600_000,
186
+ createApi = (options) => new MatrixApi(options),
187
+ cryptoStore = null,
188
+ createCrypto = null,
189
+ isKnownCommand,
190
+ } = {}) {
191
+ const homeserver = validateMatrixHomeserver(config.homeserver);
192
+ if (!homeserver) throw new TypeError('Matrix runtime requires a valid homeserver');
193
+ const token = cleanString(accessToken);
194
+ const secret = cleanString(password);
195
+ const userId = cleanString(config.userId);
196
+ if (!token && !(secret && userId)) {
197
+ throw new TypeError('Matrix runtime requires an access token or a user id with password');
198
+ }
199
+ this.#config = Object.freeze({
200
+ ...config,
201
+ homeserver,
202
+ maxMessageLength: Number.isSafeInteger(config.maxMessageLength)
203
+ ? Math.min(Math.max(config.maxMessageLength, 500), 65_535)
204
+ : DEFAULT_MAX_MESSAGE_LENGTH,
205
+ maxMediaBytes: Number.isSafeInteger(config.maxMediaBytes) && config.maxMediaBytes > 0
206
+ ? Math.min(config.maxMediaBytes, 104_857_600)
207
+ : DEFAULT_MAX_MEDIA_BYTES,
208
+ requireMention: config.requireMention !== false,
209
+ processNotices: config.processNotices === true,
210
+ allowRoomMentions: config.allowRoomMentions === true,
211
+ reactions: config.reactions !== false,
212
+ autoJoinInvites: config.autoJoinInvites === 'all' ? 'all' : 'authorized',
213
+ e2eeMode: resolveE2eeMode(config.e2eeMode),
214
+ });
215
+ this.#auth = Object.freeze(token ? { accessToken: token } : { password: secret, userId });
216
+ this.#harness = harness;
217
+ this.#state = state;
218
+ this.#sidecar = sidecar;
219
+ this.#contextEnhancement = contextEnhancement;
220
+ this.#accessPolicy = accessPolicy;
221
+ this.#status = status;
222
+ this.#status.e2eeMode = this.#config.e2eeMode;
223
+ this.#logger = logger;
224
+ this.#createApi = createApi;
225
+ if (cryptoStore != null
226
+ && (typeof cryptoStore.load !== 'function'
227
+ || typeof cryptoStore.bootstrap !== 'function'
228
+ || typeof cryptoStore.apply !== 'function')) {
229
+ throw new TypeError('Matrix crypto store is missing the load/bootstrap/apply contract');
230
+ }
231
+ this.#cryptoStore = cryptoStore ?? null;
232
+ this.#createCrypto = typeof createCrypto === 'function'
233
+ ? createCrypto
234
+ : (options) => new MatrixCryptoEngine(options);
235
+ this.#isKnownCommand = typeof isKnownCommand === 'function' ? isKnownCommand : () => false;
236
+ this.#replyTimeoutMs = replyTimeoutMs;
237
+ this.#patterns = compileIgnorePatterns(config.ignoreUserPatterns);
238
+ for (const roomId of toRoomSet(config.freeResponseRooms)) this.#freeRooms.add(roomId);
239
+ this.#allowedRooms = toRoomSet(config.allowedRooms);
240
+ }
241
+
242
+ #freeRooms = new Set();
243
+ #allowedRooms = new Set();
244
+
245
+ get status() {
246
+ return this.#status;
247
+ }
248
+
249
+ async start() {
250
+ if (this.#started && this.#status.ready) return;
251
+ this.#stopped = false;
252
+ this.#started = true;
253
+ const generation = ++this.#generation;
254
+ const homeserver = this.#config.homeserver;
255
+ let accessToken = this.#auth.accessToken ?? null;
256
+ let resolvedUserId = 'userId' in this.#auth ? this.#auth.userId : cleanString(this.#config.userId);
257
+ let resolvedDeviceId = cleanString(this.#config.deviceId);
258
+ if (!accessToken) {
259
+ const session = await performMatrixPasswordLogin({
260
+ homeserver,
261
+ userId: resolvedUserId,
262
+ password: this.#auth.password,
263
+ ...(resolvedDeviceId ? { deviceId: resolvedDeviceId } : {}),
264
+ });
265
+ accessToken = session.accessToken;
266
+ resolvedUserId = session.userId;
267
+ if (session.deviceId && resolvedDeviceId && session.deviceId !== resolvedDeviceId) {
268
+ this.#logger.warn?.(t('Matrix 配置的 device_id 与服务端实际设备不一致,以服务端设备为准。'));
269
+ }
270
+ resolvedDeviceId = session.deviceId ?? resolvedDeviceId;
271
+ }
272
+ const api = this.#createApi({ homeserver, accessToken, userId: resolvedUserId });
273
+ const identity = await api.whoami();
274
+ const verifiedUserId = cleanString(identity?.user_id) ?? resolvedUserId;
275
+ const verifiedDeviceId = cleanString(identity?.device_id);
276
+ if (!verifiedUserId) throw new Error('Matrix whoami returned no user id');
277
+ if (resolvedUserId && verifiedUserId.toLowerCase() !== resolvedUserId.toLowerCase()) {
278
+ this.#logger.warn?.(
279
+ t('Matrix whoami 返回的用户 {verified} 与配置的用户 {configured} 不一致,以 whoami 结果为准。'),
280
+ { verified: verifiedUserId, configured: resolvedUserId },
281
+ );
282
+ }
283
+ if (verifiedDeviceId && resolvedDeviceId && verifiedDeviceId !== resolvedDeviceId) {
284
+ this.#logger.warn?.(
285
+ t('Matrix 配置的 device_id {configured} 与令牌绑定设备 {verified} 不一致,令牌仅能为其设备共享密钥,以令牌设备为准。'),
286
+ { configured: resolvedDeviceId, verified: verifiedDeviceId },
287
+ );
288
+ }
289
+ this.#api = api;
290
+ this.#botUserId = verifiedUserId;
291
+ this.#deviceId = verifiedDeviceId ?? resolvedDeviceId;
292
+ await this.#startCrypto(api, verifiedUserId, this.#deviceId);
293
+ if (generation !== this.#generation) return;
294
+ this.#clock = new ClockSkewGuard({});
295
+ this.#ring.seed(this.#state.snapshot?.()?.seenMessageIds ?? []);
296
+ this.#bridge = new MatrixHarnessBridge({
297
+ descriptor: { ...MATRIX_DESCRIPTOR, reactions: this.#config.reactions ? MATRIX_DESCRIPTOR.reactions : {} },
298
+ bot: this.#createBotClient(),
299
+ harness: this.#harness,
300
+ state: this.#state,
301
+ contextEnhancement: this.#contextEnhancement,
302
+ accessPolicy: this.#accessPolicy,
303
+ status: this.#status,
304
+ logger: this.#logger,
305
+ replyTimeoutMs: this.#replyTimeoutMs,
306
+ });
307
+ await this.#initialSync();
308
+ if (generation !== this.#generation) return;
309
+ this.#status.ready = true;
310
+ this.#status.connectionState = 'connected';
311
+ this.#status.harnessReachable = true;
312
+ this.#status.lastConnectedAt = new Date().toISOString();
313
+ this.#status.lastCheckedAt = this.#status.lastConnectedAt;
314
+ this.#status.lastError = null;
315
+ this.#status.startedAt ??= this.#status.lastConnectedAt;
316
+ this.#status.e2eeActive = this.#e2eeActive;
317
+ this.#reconnectIndex = 0;
318
+ this.#syncTask = Promise.resolve().then(() => this.#syncLoop(generation));
319
+ }
320
+
321
+ async stop() {
322
+ if (this.#stopped) return;
323
+ this.#stopped = true;
324
+ this.#started = false;
325
+ this.#generation += 1;
326
+ if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
327
+ this.#reconnectTimer = null;
328
+ for (const task of this.#inviteTasks.values()) task.abort?.();
329
+ this.#inviteTasks.clear();
330
+ const bridge = this.#bridge;
331
+ this.#bridge = null;
332
+ const crypto = this.#crypto;
333
+ this.#crypto = null;
334
+ this.#e2eeActive = false;
335
+ this.#status.e2eeActive = false;
336
+ this.#api = null;
337
+ await Promise.race([
338
+ bridge?.waitForIdle() ?? Promise.resolve(),
339
+ new Promise((resolve) => setTimeout(resolve, 2_000).unref?.()),
340
+ ]);
341
+ await crypto?.stop?.().catch(() => undefined);
342
+ this.#status.ready = false;
343
+ this.#status.connectionState = 'idle';
344
+ }
345
+
346
+ async sendConnectionTest(text) {
347
+ const bridge = this.#bridge;
348
+ if (!bridge) throw new Error('Matrix bot is not connected');
349
+ return await bridge.sendConnectionTest(text);
350
+ }
351
+
352
+ async sendProactiveText(target, text, options = {}) {
353
+ const api = this.#api;
354
+ if (!api) throw new Error('Matrix bot is not connected');
355
+ const normalized = normalizeMatrixDeliveryTarget(target ?? {});
356
+ if (normalized.error) {
357
+ const error = new Error(`Matrix delivery target is invalid: ${normalized.error}`);
358
+ error.code = 'invalid-target';
359
+ throw error;
360
+ }
361
+ const route = normalized.value;
362
+ if (route.kind === 'dm') {
363
+ const roomId = await this.#resolveDmRoom(route.userId, options);
364
+ await this.#sendRoomText({ roomId, threadId: null }, text, options);
365
+ return { sent: true };
366
+ }
367
+ await this.#sendRoomText({ roomId: route.roomId, threadId: route.threadId ?? null }, text, options);
368
+ return { sent: true };
369
+ }
370
+
371
+ // ---- 端到端加密 ----
372
+
373
+ async #startCrypto(api, userId, deviceId) {
374
+ this.#crypto = null;
375
+ this.#e2eeActive = false;
376
+ this.#status.cryptoStats = null;
377
+ this.#status.lastCryptoError = null;
378
+ if (this.#config.e2eeMode === 'off') return;
379
+ if (!this.#cryptoStore) {
380
+ this.#status.lastCryptoError = safeErrorInfo(new Error('matrix crypto store is not configured'));
381
+ if (this.#config.e2eeMode === 'required') {
382
+ throw new Error(t('端到端加密模式为 required,但未配置加密状态存储,已拒绝建立加密连接。'));
383
+ }
384
+ this.#logger.warn?.(t('Matrix 端到端加密未配置状态存储,加密房间消息将明确降级跳过。'));
385
+ return;
386
+ }
387
+ let engine = null;
388
+ try {
389
+ await this.#cryptoStore.load();
390
+ engine = this.#createCrypto({
391
+ api,
392
+ store: this.#cryptoStore,
393
+ userId,
394
+ deviceId,
395
+ logger: this.#logger,
396
+ });
397
+ engine.setPendingMessageHandler((roomId, event) => this.#handleTimelineEvent(roomId, event));
398
+ await engine.start();
399
+ this.#crypto = engine;
400
+ this.#e2eeActive = true;
401
+ this.#status.cryptoStats = summarizeCryptoStats(engine.getStats());
402
+ this.#logger.info?.(t('Matrix 端到端加密引擎已就绪,设备密钥与一次性密钥已注册。'));
403
+ } catch (error) {
404
+ this.#e2eeActive = false;
405
+ this.#crypto = null;
406
+ this.#status.cryptoStats = null;
407
+ this.#status.lastCryptoError = safeErrorInfo(error);
408
+ await engine?.stop?.().catch(() => undefined);
409
+ if (this.#config.e2eeMode === 'required') {
410
+ const reason = String(error?.message ?? error).replaceAll(/mxc:\/\/[^\s]+/g, 'mxc://…').slice(0, 300);
411
+ throw new Error(t('端到端加密模式为 required,但加密引擎启动失败,已拒绝建立加密连接:{reason}', { reason }));
412
+ }
413
+ this.#logger.warn?.(t('Matrix 端到端加密引擎启动失败,加密房间消息将明确降级跳过。'), error?.message ?? error);
414
+ }
415
+ }
416
+
417
+ async #dispatchToDeviceEvents(events) {
418
+ if (!this.#crypto || !Array.isArray(events)) return;
419
+ await this.#crypto.handleToDeviceEvents(events).catch(() => undefined);
420
+ }
421
+
422
+ // ---- 连接与 sync 循环 ----
423
+
424
+ async #initialSync() {
425
+ const api = this.#api;
426
+ const initial = await api.sync({ timeout: 0 });
427
+ const rooms = initial?.rooms ?? {};
428
+ for (const roomId of Object.keys(rooms.join ?? {})) {
429
+ if (!isMatrixRoomId(roomId)) continue;
430
+ this.#joinedRooms.add(roomId);
431
+ for (const event of initialStateEventsOf(rooms.join?.[roomId])) {
432
+ if (event?.type === 'm.room.encryption') this.#encryptedRooms.add(roomId);
433
+ }
434
+ }
435
+ const direct = await api.getAccountData('m.direct').catch(() => null);
436
+ for (const list of Object.values(direct ?? {})) {
437
+ if (!Array.isArray(list)) continue;
438
+ for (const roomId of list) if (isMatrixRoomId(roomId)) this.#dmRooms.add(roomId);
439
+ }
440
+ for (const roomId of this.#sidecar.dmRooms()) this.#dmRooms.add(roomId);
441
+ for (const roomId of this.#sidecar.joinedRooms()) this.#joinedRooms.add(roomId);
442
+ await this.#classifyUnknownRooms();
443
+ for (const roomId of Object.keys(rooms.invite ?? {})) {
444
+ const inviter = inviteSenderOf(rooms.invite?.[roomId]);
445
+ this.#scheduleInviteJoin(roomId, inviter);
446
+ }
447
+ // Queued to-device room keys land before the offline timeline replay so queued ciphertext can decrypt on first sight.
448
+ await this.#dispatchToDeviceEvents(initial?.to_device?.events);
449
+ for (const [roomId, room] of Object.entries(rooms.join ?? {})) {
450
+ for (const event of timelineEventsOf(room)) await this.#handleTimelineEvent(roomId, event);
451
+ }
452
+ if (typeof initial?.next_batch === 'string' && initial.next_batch) {
453
+ this.#lastBatch = initial.next_batch;
454
+ await this.#sidecar.apply({
455
+ nextBatch: initial.next_batch,
456
+ joinedRooms: [...this.#joinedRooms],
457
+ dmRooms: [...this.#dmRooms],
458
+ });
459
+ }
460
+ this.#status.joinedRooms = this.#joinedRooms.size;
461
+ this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
462
+ }
463
+
464
+ async #classifyUnknownRooms() {
465
+ for (const roomId of this.#joinedRooms) {
466
+ if (this.#dmRooms.has(roomId)) continue;
467
+ const count = await this.#api.getJoinedMemberCount(roomId).catch(() => null);
468
+ if (count !== null && count <= 2) this.#dmRooms.add(roomId);
469
+ }
470
+ }
471
+
472
+ async #syncLoop(generation) {
473
+ while (!this.#stopped && generation === this.#generation && this.#api) {
474
+ try {
475
+ const data = await this.#api.sync({
476
+ since: this.#lastBatch ?? undefined,
477
+ timeout: SYNC_LONG_POLL_MS,
478
+ });
479
+ if (generation !== this.#generation || this.#stopped) return;
480
+ await this.#handleSyncData(data);
481
+ if (this.#crypto) {
482
+ await this.#crypto.maintain?.().catch(() => undefined);
483
+ this.#status.cryptoStats = summarizeCryptoStats(this.#crypto.getStats());
484
+ }
485
+ this.#status.lastCheckedAt = new Date().toISOString();
486
+ this.#reconnectIndex = 0;
487
+ } catch (error) {
488
+ if (generation !== this.#generation || this.#stopped) return;
489
+ const permanent = error instanceof MatrixApiError
490
+ ? error.permanent
491
+ : /m_unknown_token|unauthorized|forbidden/i.test(String(error?.message ?? ''));
492
+ if (permanent) {
493
+ this.#status.connectionState = 'failed';
494
+ this.#status.harnessReachable = false;
495
+ this.#status.lastError = safeErrorInfo(error);
496
+ this.#logger.warn?.('[dsh-im:matrix] sync loop stopped on a permanent auth error; reconnecting', error);
497
+ this.#scheduleReconnect(generation);
498
+ return;
499
+ }
500
+ this.#logger.warn?.('[dsh-im:matrix] sync loop error; retrying', error);
501
+ await new Promise((resolve) => setTimeout(resolve, SYNC_RETRY_DELAY_MS).unref?.());
502
+ }
503
+ }
504
+ }
505
+
506
+ #scheduleReconnect(generation) {
507
+ if (this.#stopped || generation !== this.#generation || this.#reconnectTimer !== null) return;
508
+ const delayMs = RECONNECT_DELAYS_MS[Math.min(this.#reconnectIndex, RECONNECT_DELAYS_MS.length - 1)];
509
+ this.#reconnectIndex += 1;
510
+ this.#reconnectTimer = setTimeout(() => {
511
+ this.#reconnectTimer = null;
512
+ if (this.#stopped || generation !== this.#generation) return;
513
+ this.#status.connectionState = 'connecting';
514
+ void this.start().catch((error) => {
515
+ this.#logger.warn?.('[dsh-im:matrix] reconnect attempt failed; the supervisor will retry', error);
516
+ });
517
+ }, delayMs);
518
+ this.#reconnectTimer.unref?.();
519
+ }
520
+
521
+ async #handleSyncData(data) {
522
+ const rooms = data?.rooms ?? {};
523
+ await this.#dispatchToDeviceEvents(data?.to_device?.events);
524
+ let joinedChanged = false;
525
+ for (const [roomId, room] of Object.entries(rooms.join ?? {})) {
526
+ if (!isMatrixRoomId(roomId)) continue;
527
+ if (!this.#joinedRooms.has(roomId)) {
528
+ this.#joinedRooms.add(roomId);
529
+ joinedChanged = true;
530
+ }
531
+ for (const event of initialStateEventsOf(room)) {
532
+ if (event?.type === 'm.room.encryption') this.#encryptedRooms.add(roomId);
533
+ // A membership change invalidates the shared-device set so the next send re-shares the room key.
534
+ else if (event?.type === 'm.room.member' && event?.state_key && this.#crypto) this.#crypto.invalidateRoomSharing(roomId);
535
+ }
536
+ for (const event of timelineEventsOf(room)) await this.#handleTimelineEvent(roomId, event);
537
+ }
538
+ for (const [roomId, room] of Object.entries(rooms.invite ?? {})) {
539
+ if (!isMatrixRoomId(roomId)) continue;
540
+ this.#scheduleInviteJoin(roomId, inviteSenderOf(room));
541
+ }
542
+ if (typeof data?.next_batch === 'string' && data.next_batch) {
543
+ this.#lastBatch = data.next_batch;
544
+ await this.#sidecar.apply({
545
+ nextBatch: data.next_batch,
546
+ ...(joinedChanged ? { joinedRooms: [...this.#joinedRooms] } : {}),
547
+ });
548
+ this.#status.joinedRooms = this.#joinedRooms.size;
549
+ this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
550
+ }
551
+ }
552
+
553
+ async #handleTimelineEvent(roomId, event) {
554
+ if (event?.type === 'm.room.encrypted') {
555
+ this.#noteEncryptedRoom(roomId);
556
+ if (!this.#crypto) return;
557
+ if (typeof event?.event_id !== 'string' || !event.event_id) return;
558
+ const decrypted = await this.#crypto.decryptRoomEvent(roomId, event).catch(() => null);
559
+ if (!decrypted || typeof decrypted.content !== 'object' || decrypted.content === null) return;
560
+ event = { ...event, type: 'm.room.message', content: decrypted.content };
561
+ }
562
+ if (event?.type !== 'm.room.message' && event?.type !== 'm.room.encryption') return;
563
+ if (event?.type === 'm.room.encryption') {
564
+ this.#encryptedRooms.add(roomId);
565
+ return;
566
+ }
567
+ const content = event?.content;
568
+ if (content?.['m.relates_to']?.rel_type === 'm.replace') return;
569
+ const isDirect = this.#dmRooms.has(roomId);
570
+ const outcome = normalizeMatrixTimelineEvent({
571
+ event,
572
+ roomId,
573
+ botUserId: this.#botUserId,
574
+ isDirect,
575
+ config: {
576
+ requireMention: this.#config.requireMention,
577
+ processNotices: this.#config.processNotices,
578
+ freeResponseRooms: this.#freeRooms,
579
+ allowedRooms: this.#allowedRooms.size > 0 ? this.#allowedRooms : null,
580
+ },
581
+ patterns: this.#patterns,
582
+ ring: this.#ring,
583
+ clock: this.#clock,
584
+ deps: { createMediaSource: (mediaContent, msgtype) => this.#createMediaSource(mediaContent, msgtype) },
585
+ });
586
+ if (outcome.drop) {
587
+ if (outcome.warnSkew && !this.#status.lastClockSkewAt) {
588
+ this.#status.lastClockSkewAt = new Date().toISOString();
589
+ this.#logger.warn?.(t('Matrix 收到的时间戳持续远落后于本机时间,检测到本机时钟超前,请校准系统时间后重启机器人。'));
590
+ }
591
+ return;
592
+ }
593
+ const message = { ...outcome.message };
594
+ if (message.addressed && typeof message.content === 'string' && message.content.startsWith('!')) {
595
+ const resolved = resolveBangMatrixCommand(message.content, (name) => this.#isKnownCommand(name));
596
+ if (resolved !== message.content) {
597
+ message.content = resolved;
598
+ message.addressed = true;
599
+ }
600
+ }
601
+ void Promise.resolve(this.#bridge?.accept(message)).catch((error) => {
602
+ this.#logger.warn?.('[dsh-im:matrix] inbound message handling failed:', error?.message ?? error);
603
+ });
604
+ if (message.addressed) {
605
+ void this.#api?.setTyping(message.roomId, { typing: true, timeoutMs: 20_000 }).catch(() => undefined);
606
+ }
607
+ }
608
+
609
+ #noteEncryptedRoom(roomId) {
610
+ this.#encryptedRooms.add(roomId);
611
+ this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
612
+ if (this.#e2eeActive || this.#config.e2eeMode === 'off') return;
613
+ if (this.#notifiedEncryptedRooms.has(roomId)) return;
614
+ this.#notifiedEncryptedRooms.add(roomId);
615
+ this.#logger.warn?.(
616
+ `[dsh-im:matrix] ${roomId} ${t('端到端加密未启用或不可用,加密房间 {room} 的密文会被明确降级跳过。', { room: roomId })}`,
617
+ );
618
+ }
619
+
620
+ // ---- 邀请 join ----
621
+
622
+ #scheduleInviteJoin(roomId, inviter) {
623
+ if (this.#stopped || !isMatrixRoomId(roomId) || this.#joinedRooms.has(roomId)) return;
624
+ if (this.#sidecar.isDeclined(roomId)) return;
625
+ const allowed = this.#config.autoJoinInvites === 'all' || inviterAllowed(this.#accessPolicy, inviter);
626
+ if (!allowed) {
627
+ this.#logger.warn?.(
628
+ t('Matrix 拒绝了来自未授权用户 {inviter} 的入房邀请 {room}。'),
629
+ { inviter: inviter ?? '?', room: roomId },
630
+ );
631
+ return;
632
+ }
633
+ if (this.#inviteTasks.has(roomId)) return;
634
+ const controller = new AbortController();
635
+ this.#inviteTasks.set(roomId, controller);
636
+ void this.#joinInvitedRoom(roomId, inviter, controller).finally(() => {
637
+ this.#inviteTasks.delete(roomId);
638
+ });
639
+ }
640
+
641
+ async #joinInvitedRoom(roomId, inviter, controller) {
642
+ const timeout = AbortSignal.timeout(INVITE_JOIN_TIMEOUT_MS);
643
+ const signal = AbortSignal.any([controller.signal, timeout]);
644
+ try {
645
+ await this.#api.joinRoom(roomId, { signal });
646
+ this.#joinedRooms.add(roomId);
647
+ this.#status.joinedRooms = this.#joinedRooms.size;
648
+ await this.#sidecar.apply({ joinedRooms: [...this.#joinedRooms] });
649
+ if (inviter) await this.#recordDmRoom(roomId, inviter);
650
+ this.#logger.info?.(t('Matrix 已按授权邀请加入房间 {room}。'), { room: roomId });
651
+ } catch (error) {
652
+ const text = String(error?.message ?? '').toLowerCase();
653
+ if (DEAD_ROOM_MARKERS.some((marker) => text.includes(marker))) {
654
+ try {
655
+ await this.#api.leaveRoom(roomId, { signal: AbortSignal.timeout(10_000) });
656
+ await this.#sidecar.apply({ declinedRooms: [...this.#sidecar.declinedRooms(), roomId] });
657
+ this.#logger.info?.(t('Matrix 已婉拒失效房间的遗留邀请 {room}。'), { room: roomId });
658
+ } catch {
659
+ // A dead-room decline is best effort; a later sync reconciles again.
660
+ }
661
+ return;
662
+ }
663
+ this.#logger.warn?.('[dsh-im:matrix] invite join failed; sync will reconcile again:', error?.message ?? error);
664
+ }
665
+ }
666
+
667
+ async #recordDmRoom(roomId, inviter) {
668
+ const direct = await this.#api.getAccountData('m.direct').catch(() => null);
669
+ const map = { ...(direct && typeof direct === 'object' && !Array.isArray(direct) ? direct : {}) };
670
+ const list = Array.isArray(map[inviter]) ? [...map[inviter]] : [];
671
+ if (!list.includes(roomId)) list.push(roomId);
672
+ map[inviter] = list;
673
+ await this.#api.setAccountData('m.direct', map).catch(() => undefined);
674
+ this.#dmRooms.add(roomId);
675
+ const dmRoomByUser = { ...this.#sidecar.dmRoomByUser(), [inviter.trim().toLowerCase()]: roomId };
676
+ await this.#sidecar.apply({ dmRooms: [...this.#dmRooms], dmRoomByUser });
677
+ }
678
+
679
+ async #resolveDmRoom(userId, options = {}) {
680
+ const key = userId.trim().toLowerCase();
681
+ const known = this.#sidecar.dmRoomByUser()[key];
682
+ if (known && isMatrixRoomId(known)) return known;
683
+ const created = await this.#api.createRoom({
684
+ preset: 'private_chat',
685
+ is_direct: true,
686
+ invite: [userId],
687
+ }, options).catch(() => null);
688
+ const roomId = cleanString(created?.room_id ?? created?.room_Id);
689
+ if (!roomId || !isMatrixRoomId(roomId)) {
690
+ const error = new Error(t('Matrix 无法为该用户创建私聊房间。'));
691
+ error.code = 'dm-room-unavailable';
692
+ throw error;
693
+ }
694
+ this.#dmRooms.add(roomId);
695
+ this.#joinedRooms.add(roomId);
696
+ await this.#sidecar.apply({
697
+ dmRooms: [...this.#dmRooms],
698
+ joinedRooms: [...this.#joinedRooms],
699
+ dmRoomByUser: { ...this.#sidecar.dmRoomByUser(), [key]: roomId },
700
+ });
701
+ return roomId;
702
+ }
703
+
704
+ // ---- 出站 ----
705
+
706
+ async #sendRoomEvent(roomId, eventType, content, options = {}) {
707
+ const api = this.#api;
708
+ if (!api) throw new Error('Matrix bot is not connected');
709
+ if (this.#crypto && this.#encryptedRooms.has(roomId)) {
710
+ const encrypted = await this.#crypto.encryptForRoom(roomId, content);
711
+ return await api.sendEvent(roomId, 'm.room.encrypted', encrypted, options);
712
+ }
713
+ return await api.sendEvent(roomId, eventType, content, options);
714
+ }
715
+
716
+ #createMediaSource(content, msgtype) {
717
+ const encrypted = content?.file && typeof content.file === 'object' ? content.file : null;
718
+ const contentUri = cleanString(content?.url ?? encrypted?.url);
719
+ if (!contentUri || !contentUri.startsWith('mxc://')) return null;
720
+ const declaredSize = Number(content?.info?.size ?? encrypted?.size);
721
+ if (Number.isFinite(declaredSize) && declaredSize > this.#config.maxMediaBytes) return null;
722
+ const name = cleanString(content?.body) ?? cleanString(content?.filename) ?? undefined;
723
+ const api = this.#api;
724
+ const mediaType = cleanString(content?.info?.mimetype ?? content?.mimetype);
725
+ const load = async (options = {}) => {
726
+ try {
727
+ return await api.downloadContent(contentUri, {
728
+ ...options,
729
+ maxBytes: this.#config.maxMediaBytes,
730
+ });
731
+ } catch {
732
+ return null;
733
+ }
734
+ };
735
+ const source = { name, ...(mediaType ? { mediaType } : {}), load };
736
+ if (msgtype === 'm.image' && mediaType && !IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase())) return null;
737
+ if (msgtype === 'm.image') return { images: [source], files: [] };
738
+ if (msgtype === 'm.audio' || msgtype === 'm.video' || msgtype === 'm.file' || msgtype === 'm.sticker') {
739
+ return { images: [], files: [source] };
740
+ }
741
+ return null;
742
+ }
743
+
744
+ #createBotClient() {
745
+ return {
746
+ sendText: async (target, text) => await this.#sendRoomText(target, text),
747
+ sendTyping: async (target) => {
748
+ await this.#api?.setTyping(target?.roomId ?? '', { typing: true, timeoutMs: 20_000 }).catch(() => undefined);
749
+ },
750
+ addReaction: async (target, key) => {
751
+ if (!isMatrixRoomId(target?.roomId) || !isMatrixEventId(target?.eventId)) return undefined;
752
+ await this.#sendRoomEvent(target.roomId, 'm.reaction', buildMatrixReactionContent(target.eventId, key));
753
+ return key;
754
+ },
755
+ removeReaction: async (target, key) => {
756
+ if (!isMatrixRoomId(target?.roomId) || !isMatrixEventId(target?.eventId) || !this.#botUserId) return;
757
+ const listing = await this.#api.listRelations(target.roomId, target.eventId, 'm.annotation');
758
+ let events = Array.isArray(listing?.events) ? listing.events : [];
759
+ if (this.#crypto) {
760
+ const opened = [];
761
+ for (const event of events) {
762
+ if (event?.type !== 'm.room.encrypted') { opened.push(event); continue; }
763
+ const decrypted = await this.#crypto.decryptRoomEvent(target.roomId, event).catch(() => null);
764
+ if (decrypted?.content && typeof decrypted.content === 'object') opened.push({ ...event, content: decrypted.content });
765
+ }
766
+ events = opened;
767
+ }
768
+ const own = events.filter((event) => (
769
+ typeof event?.sender === 'string'
770
+ && event.sender.toLowerCase() === this.#botUserId.toLowerCase()
771
+ && event?.content?.['m.reaction'] === key
772
+ ));
773
+ for (const event of own.slice(0, 5)) {
774
+ await this.#api.redactEvent(target.roomId, event.event_id).catch(() => undefined);
775
+ }
776
+ },
777
+ sendImage: async (target, file) => await this.#sendArtifact(target, file, true),
778
+ sendFile: async (target, file) => await this.#sendArtifact(target, file, false),
779
+ openStream: (target) => this.#openStream(target),
780
+ };
781
+ }
782
+
783
+ async #sendRoomText(target, text, options = {}) {
784
+ const api = this.#api;
785
+ if (!api) throw new Error('Matrix bot is not connected');
786
+ const roomId = typeof target?.roomId === 'string' ? target.roomId : '';
787
+ if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix reply target requires a valid room id');
788
+ const threadId = typeof target?.threadId === 'string' && isMatrixEventId(target.threadId)
789
+ ? target.threadId : null;
790
+ const replyToEventId = typeof target?.replyToEventId === 'string' && isMatrixEventId(target.replyToEventId)
791
+ ? target.replyToEventId : null;
792
+ const chunks = splitMessageText(text, this.#config.maxMessageLength);
793
+ const providerMessageIds = [];
794
+ for (const chunk of chunks) {
795
+ const content = buildMatrixTextContent({
796
+ text: chunk,
797
+ mentionUserIds: extractOutboundMentions(chunk),
798
+ roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk),
799
+ });
800
+ applyMatrixRelations(content, { threadId, replyToEventId });
801
+ const result = await this.#sendRoomEvent(roomId, 'm.room.message', content, {
802
+ ...(options.signal ? { signal: options.signal } : {}),
803
+ });
804
+ if (typeof result?.event_id === 'string') providerMessageIds.push(result.event_id);
805
+ }
806
+ return { providerMessageIds };
807
+ }
808
+
809
+ async #openStream(target) {
810
+ const roomId = typeof target?.roomId === 'string' ? target.roomId : '';
811
+ if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix stream target requires a valid room id');
812
+ if (!this.#api) throw new Error('Matrix bot is not connected');
813
+ const initialText = t('正在处理…');
814
+ const opened = await this.#sendRoomText(target, initialText);
815
+ const messageId = opened.providerMessageIds[0] ?? null;
816
+ const threadId = typeof target?.threadId === 'string' && isMatrixEventId(target.threadId)
817
+ ? target.threadId : null;
818
+ const stream = createEditableMessageStream({
819
+ initialText,
820
+ limit: this.#config.maxMessageLength,
821
+ updateIntervalMs: EDIT_STREAM_INTERVAL_MS,
822
+ create: async () => messageId,
823
+ edit: async (streamMessageId, text) => {
824
+ if (!isMatrixEventId(streamMessageId)) return;
825
+ const content = buildMatrixEditContent({
826
+ originalContent: { msgtype: 'm.text', body: initialText },
827
+ newText: text,
828
+ eventId: streamMessageId,
829
+ });
830
+ if (threadId) {
831
+ content['m.relates_to'] = {
832
+ ...content['m.relates_to'],
833
+ chain: [{ event_id: threadId, origin_server: null, origin_sender: null, rel_type: 'm.thread' }],
834
+ };
835
+ }
836
+ await this.#sendRoomEvent(roomId, 'm.room.message', content);
837
+ },
838
+ sendRemainder: async (chunk) => await this.#sendRoomText(target, chunk),
839
+ messageIdForResult: (result) => result?.providerMessageIds?.at(-1) ?? null,
840
+ logger: this.#logger,
841
+ });
842
+ await stream.start();
843
+ return stream;
844
+ }
845
+
846
+ async #sendArtifact(target, file, preferImage) {
847
+ const api = this.#api;
848
+ if (!api) throw new Error('Matrix bot is not connected');
849
+ const roomId = typeof target?.roomId === 'string' ? target.roomId : '';
850
+ if (!isMatrixRoomId(roomId)) throw new TypeError('Matrix artifact target requires a valid room id');
851
+ const bytes = await file?.load?.({}).catch(() => null);
852
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) {
853
+ const error = new Error(t('结果文件内容为空或下载失败。'));
854
+ error.code = 'artifact-provider-failed';
855
+ throw error;
856
+ }
857
+ if (bytes.byteLength > this.#config.maxMediaBytes) {
858
+ const error = new Error(t('结果文件超过 Matrix 媒体大小上限。'));
859
+ error.code = 'artifact-too-large';
860
+ throw error;
861
+ }
862
+ const mediaType = cleanString(file?.mediaType) ?? 'application/octet-stream';
863
+ const fileName = cleanString(file?.name) ?? (preferImage ? 'image.jpg' : 'file.bin');
864
+ const contentUri = await api.uploadMedia(bytes, { filename: fileName, mediaType }).catch((cause) => {
865
+ const error = new Error(t('Matrix 媒体上传失败。'));
866
+ error.code = 'artifact-provider-failed';
867
+ error.cause = cause;
868
+ throw error;
869
+ });
870
+ const threadId = typeof target?.threadId === 'string' && isMatrixEventId(target.threadId)
871
+ ? target.threadId : null;
872
+ const imageLike = preferImage && mediaType.toLowerCase().startsWith('image/');
873
+ const baseContent = {
874
+ body: fileName,
875
+ filename: fileName,
876
+ url: contentUri,
877
+ };
878
+ const info = { mimetype: mediaType, size: bytes.byteLength };
879
+ const attempts = imageLike
880
+ ? [{ msgtype: 'm.image', extra: { info } }, { msgtype: 'm.file', extra: { 'm.file': info } }]
881
+ : [{ msgtype: 'm.file', extra: { 'm.file': info } }];
882
+ let lastError = null;
883
+ for (const attempt of attempts) {
884
+ const content = { ...baseContent, msgtype: attempt.msgtype, ...attempt.extra };
885
+ applyMatrixRelations(content, { threadId });
886
+ try {
887
+ await this.#sendRoomEvent(roomId, 'm.room.message', content);
888
+ return { sent: true, msgtype: attempt.msgtype };
889
+ } catch (error) {
890
+ lastError = error;
891
+ if (!(error instanceof MatrixApiError) || (error.code !== 'matrix-rejected' && error.code !== 'matrix-forbidden')) {
892
+ throw error;
893
+ }
894
+ }
895
+ }
896
+ throw lastError ?? new Error(t('Matrix 文件消息发送失败。'));
897
+ }
898
+
899
+ #lastBatch = null;
900
+ }