@xmanrui/dsh-im 4.26.0 → 4.27.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.
@@ -18,10 +18,16 @@ import {
18
18
  ClockSkewGuard,
19
19
  compileIgnorePatterns,
20
20
  EventDedupeRing,
21
+ isMatrixCommandLike,
21
22
  normalizeMatrixDeliveryTarget,
22
23
  normalizeMatrixTimelineEvent,
23
24
  resolveBangMatrixCommand,
24
25
  } from './matrix-normalize.mjs';
26
+ import {
27
+ DEFAULT_ROOM_HISTORY_LIMIT,
28
+ formatRoomContextBlock,
29
+ roomContextDayStartTs,
30
+ } from './matrix-room-history.mjs';
25
31
  import {
26
32
  applyMatrixRelations,
27
33
  buildMatrixEditContent,
@@ -38,6 +44,13 @@ const INVITE_JOIN_TIMEOUT_MS = 45_000;
38
44
  const DEFAULT_MAX_MESSAGE_LENGTH = 16_000;
39
45
  const DEFAULT_MAX_MEDIA_BYTES = 104_857_600;
40
46
  const EDIT_STREAM_INTERVAL_MS = 350;
47
+
48
+ function safeInt(value, min, max, fallback) {
49
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
50
+ const rounded = Math.trunc(value);
51
+ if (!Number.isSafeInteger(rounded)) return fallback;
52
+ return Math.min(Math.max(rounded, min), max);
53
+ }
41
54
  const DEAD_ROOM_MARKERS = Object.freeze(['no servers', 'room not found']);
42
55
  const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
43
56
 
@@ -106,6 +119,16 @@ function inviteSenderOf(inviteRoom) {
106
119
  return null;
107
120
  }
108
121
 
122
+ // A room is a direct chat only when the homeserver itself says so: the invite's own membership event
123
+ // carries is_direct, or the account's m.direct listing names the room. A two-member room is an
124
+ // ordinary room, where the mention gate must keep applying, so the joined-member count is never a signal.
125
+ function inviteMarksDirect(inviteRoom) {
126
+ const events = Array.isArray(inviteRoom?.invite_state?.events) ? inviteRoom.invite_state.events : [];
127
+ return events.some((event) => event?.type === 'm.room.member'
128
+ && event?.content?.membership === 'invite'
129
+ && event?.content?.is_direct === true);
130
+ }
131
+
109
132
  function timelineEventsOf(joinRoom) {
110
133
  const events = Array.isArray(joinRoom?.timeline?.events) ? joinRoom.timeline.events : [];
111
134
  return events.filter((event) => event && typeof event === 'object' && event.state_key === undefined);
@@ -186,6 +209,7 @@ export class MatrixRuntime {
186
209
  createApi = (options) => new MatrixApi(options),
187
210
  cryptoStore = null,
188
211
  createCrypto = null,
212
+ roomHistory = null,
189
213
  isKnownCommand,
190
214
  } = {}) {
191
215
  const homeserver = validateMatrixHomeserver(config.homeserver);
@@ -233,6 +257,17 @@ export class MatrixRuntime {
233
257
  ? createCrypto
234
258
  : (options) => new MatrixCryptoEngine(options);
235
259
  this.#isKnownCommand = typeof isKnownCommand === 'function' ? isKnownCommand : () => false;
260
+ this.#roomHistory = roomHistory
261
+ && typeof roomHistory.append === 'function'
262
+ && typeof roomHistory.consumePending === 'function'
263
+ && typeof roomHistory.pending === 'function'
264
+ && typeof roomHistory.search === 'function'
265
+ ? roomHistory
266
+ : null;
267
+ this.#roomContextLimit = safeInt(config.roomContextLimit, 1, 500, 50);
268
+ this.#roomContextMaxChars = safeInt(config.roomContextMaxChars, 200, 200_000, 8_000);
269
+ this.#roomContextTzOffsetMinutes = safeInt(config.roomContextTzOffsetMinutes, -840, 840, 0);
270
+ this.#roomContextEnabled = config.roomContextEnabled !== false;
236
271
  this.#replyTimeoutMs = replyTimeoutMs;
237
272
  this.#patterns = compileIgnorePatterns(config.ignoreUserPatterns);
238
273
  for (const roomId of toRoomSet(config.freeResponseRooms)) this.#freeRooms.add(roomId);
@@ -241,6 +276,11 @@ export class MatrixRuntime {
241
276
 
242
277
  #freeRooms = new Set();
243
278
  #allowedRooms = new Set();
279
+ #roomHistory = null;
280
+ #roomContextLimit = 50;
281
+ #roomContextMaxChars = 8_000;
282
+ #roomContextTzOffsetMinutes = 0;
283
+ #roomContextEnabled = true;
244
284
 
245
285
  get status() {
246
286
  return this.#status;
@@ -437,12 +477,13 @@ export class MatrixRuntime {
437
477
  if (!Array.isArray(list)) continue;
438
478
  for (const roomId of list) if (isMatrixRoomId(roomId)) this.#dmRooms.add(roomId);
439
479
  }
440
- for (const roomId of this.#sidecar.dmRooms()) this.#dmRooms.add(roomId);
480
+ // A persisted direct chat survives only where the bot really routes a person to it, or where the
481
+ // account data above re-registers it: residue from the retired member-count rule is forgotten here.
482
+ const routedTo = new Set(Object.values(this.#sidecar.dmRoomByUser()).filter((roomId) => isMatrixRoomId(roomId)));
483
+ for (const roomId of this.#sidecar.dmRooms()) if (routedTo.has(roomId)) this.#dmRooms.add(roomId);
441
484
  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);
485
+ for (const [roomId, room] of Object.entries(rooms.invite ?? {})) {
486
+ this.#scheduleInviteJoin(roomId, inviteSenderOf(room), inviteMarksDirect(room));
446
487
  }
447
488
  // Queued to-device room keys land before the offline timeline replay so queued ciphertext can decrypt on first sight.
448
489
  await this.#dispatchToDeviceEvents(initial?.to_device?.events);
@@ -461,14 +502,6 @@ export class MatrixRuntime {
461
502
  this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
462
503
  }
463
504
 
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
505
  async #syncLoop(generation) {
473
506
  while (!this.#stopped && generation === this.#generation && this.#api) {
474
507
  try {
@@ -537,7 +570,7 @@ export class MatrixRuntime {
537
570
  }
538
571
  for (const [roomId, room] of Object.entries(rooms.invite ?? {})) {
539
572
  if (!isMatrixRoomId(roomId)) continue;
540
- this.#scheduleInviteJoin(roomId, inviteSenderOf(room));
573
+ this.#scheduleInviteJoin(roomId, inviteSenderOf(room), inviteMarksDirect(room));
541
574
  }
542
575
  if (typeof data?.next_batch === 'string' && data.next_batch) {
543
576
  this.#lastBatch = data.next_batch;
@@ -588,6 +621,7 @@ export class MatrixRuntime {
588
621
  this.#status.lastClockSkewAt = new Date().toISOString();
589
622
  this.#logger.warn?.(t('Matrix 收到的时间戳持续远落后于本机时间,检测到本机时钟超前,请校准系统时间后重启机器人。'));
590
623
  }
624
+ if (outcome.drop === 'mention-required') this.#recordRoomAmbient(roomId, event);
591
625
  return;
592
626
  }
593
627
  const message = { ...outcome.message };
@@ -598,6 +632,22 @@ export class MatrixRuntime {
598
632
  message.addressed = true;
599
633
  }
600
634
  }
635
+ if (this.#roomHistory && this.#roomContextEnabled && message.kind === 'group') {
636
+ if (message.addressed) {
637
+ const commandLike = isMatrixCommandLike(message.content);
638
+ const directAddress = message.mentioned === true || Boolean(message.replyToEventId);
639
+ this.#recordRoomHuman(message, { kind: commandLike ? 'command' : 'human', injected: true });
640
+ if (!commandLike && directAddress) {
641
+ const block = this.#consumeRoomContext(message.roomId);
642
+ if (block) {
643
+ const label = t('【下面这条才是对你的提问,请只回答它】');
644
+ message.content = `${block}\n\n${label}\n${message.content}`;
645
+ }
646
+ }
647
+ } else {
648
+ this.#recordRoomHuman(message, { kind: 'human', injected: false });
649
+ }
650
+ }
601
651
  void Promise.resolve(this.#bridge?.accept(message)).catch((error) => {
602
652
  this.#logger.warn?.('[dsh-im:matrix] inbound message handling failed:', error?.message ?? error);
603
653
  });
@@ -606,6 +656,68 @@ export class MatrixRuntime {
606
656
  }
607
657
  }
608
658
 
659
+ #recordRoomAmbient(roomId, event) {
660
+ if (!this.#roomHistory || !this.#roomContextEnabled) return;
661
+ if (this.#dmRooms.has(roomId)) return;
662
+ const eventId = typeof event?.event_id === 'string' ? event.event_id : '';
663
+ const sender = typeof event?.sender === 'string' ? event.sender : '';
664
+ const body = typeof event?.content?.body === 'string' ? event.content.body.trim() : '';
665
+ if (!eventId || !sender || !body) return;
666
+ const self = this.#botUserId?.toLowerCase() ?? '';
667
+ if (self !== '' && sender.toLowerCase() === self) return;
668
+ this.#roomHistory.append({
669
+ roomId,
670
+ eventId,
671
+ sender,
672
+ text: body,
673
+ ts: Number.isSafeInteger(event?.origin_server_ts) ? event.origin_server_ts : Date.now(),
674
+ kind: 'human',
675
+ injected: false,
676
+ });
677
+ }
678
+
679
+ #recordRoomHuman(message, { kind = 'human', injected = false } = {}) {
680
+ if (!this.#roomHistory || !this.#roomContextEnabled) return;
681
+ const text = typeof message.content === 'string' ? message.content.trim() : '';
682
+ if (!text || !message.messageId || !message.senderId) return;
683
+ const self = this.#botUserId?.toLowerCase() ?? '';
684
+ if (self !== '' && message.senderId.toLowerCase() === self) return;
685
+ this.#roomHistory.append({
686
+ roomId: message.roomId,
687
+ eventId: message.messageId,
688
+ sender: message.senderId,
689
+ text,
690
+ ts: Date.now(),
691
+ kind,
692
+ injected,
693
+ });
694
+ }
695
+
696
+ #consumeRoomContext(roomId) {
697
+ if (!this.#roomHistory) return '';
698
+ const now = Date.now();
699
+ const selected = this.#roomHistory.consumePending({
700
+ roomId,
701
+ sinceTs: roomContextDayStartTs(now, this.#roomContextTzOffsetMinutes),
702
+ limit: this.#roomContextLimit,
703
+ maxChars: this.#roomContextMaxChars,
704
+ now,
705
+ });
706
+ if (selected.length === 0) return '';
707
+ // The room timeline echoes the bot's own messages too; showing them as other members' speech makes
708
+ // the model quote itself and answer its own earlier reply, so only third-party lines are presented.
709
+ const self = this.#botUserId?.toLowerCase() ?? '';
710
+ const others = selected.filter((entry) => self === '' || String(entry?.sender ?? '').toLowerCase() !== self);
711
+ if (others.length === 0) return '';
712
+ return formatRoomContextBlock(others, {
713
+ header: t('【群聊背景】以下各条是群里其他成员之间的发言,均未指向你。'),
714
+ instruction: t('这些内容只用来了解现场发生过什么。请勿逐条回应、复述、翻译或总结它们,也不要因为它们而改变下面那条提问的回答。'),
715
+ begin: t('—— 背景开始 ——'),
716
+ end: t('—— 背景结束(以上无需回应) ——'),
717
+ tzOffsetMinutes: this.#roomContextTzOffsetMinutes,
718
+ });
719
+ }
720
+
609
721
  #noteEncryptedRoom(roomId) {
610
722
  this.#encryptedRooms.add(roomId);
611
723
  this.#status.encryptedRoomsSeen = this.#encryptedRooms.size;
@@ -619,7 +731,7 @@ export class MatrixRuntime {
619
731
 
620
732
  // ---- 邀请 join ----
621
733
 
622
- #scheduleInviteJoin(roomId, inviter) {
734
+ #scheduleInviteJoin(roomId, inviter, marksDirect = false) {
623
735
  if (this.#stopped || !isMatrixRoomId(roomId) || this.#joinedRooms.has(roomId)) return;
624
736
  if (this.#sidecar.isDeclined(roomId)) return;
625
737
  const allowed = this.#config.autoJoinInvites === 'all' || inviterAllowed(this.#accessPolicy, inviter);
@@ -633,12 +745,12 @@ export class MatrixRuntime {
633
745
  if (this.#inviteTasks.has(roomId)) return;
634
746
  const controller = new AbortController();
635
747
  this.#inviteTasks.set(roomId, controller);
636
- void this.#joinInvitedRoom(roomId, inviter, controller).finally(() => {
748
+ void this.#joinInvitedRoom(roomId, inviter, controller, marksDirect).finally(() => {
637
749
  this.#inviteTasks.delete(roomId);
638
750
  });
639
751
  }
640
752
 
641
- async #joinInvitedRoom(roomId, inviter, controller) {
753
+ async #joinInvitedRoom(roomId, inviter, controller, marksDirect = false) {
642
754
  const timeout = AbortSignal.timeout(INVITE_JOIN_TIMEOUT_MS);
643
755
  const signal = AbortSignal.any([controller.signal, timeout]);
644
756
  try {
@@ -646,7 +758,7 @@ export class MatrixRuntime {
646
758
  this.#joinedRooms.add(roomId);
647
759
  this.#status.joinedRooms = this.#joinedRooms.size;
648
760
  await this.#sidecar.apply({ joinedRooms: [...this.#joinedRooms] });
649
- if (inviter) await this.#recordDmRoom(roomId, inviter);
761
+ if (inviter) await this.#recordDmRoom(roomId, inviter, marksDirect);
650
762
  this.#logger.info?.(t('Matrix 已按授权邀请加入房间 {room}。'), { room: roomId });
651
763
  } catch (error) {
652
764
  const text = String(error?.message ?? '').toLowerCase();
@@ -664,14 +776,16 @@ export class MatrixRuntime {
664
776
  }
665
777
  }
666
778
 
667
- async #recordDmRoom(roomId, inviter) {
779
+ // The inviter's cached room stays usable for outbound routing either way; only an authoritative
780
+ // is_direct invite registers the room as a direct chat for inbound handling.
781
+ async #recordDmRoom(roomId, inviter, marksDirect = false) {
668
782
  const direct = await this.#api.getAccountData('m.direct').catch(() => null);
669
783
  const map = { ...(direct && typeof direct === 'object' && !Array.isArray(direct) ? direct : {}) };
670
784
  const list = Array.isArray(map[inviter]) ? [...map[inviter]] : [];
671
785
  if (!list.includes(roomId)) list.push(roomId);
672
786
  map[inviter] = list;
673
787
  await this.#api.setAccountData('m.direct', map).catch(() => undefined);
674
- this.#dmRooms.add(roomId);
788
+ if (marksDirect) this.#dmRooms.add(roomId);
675
789
  const dmRoomByUser = { ...this.#sidecar.dmRoomByUser(), [inviter.trim().toLowerCase()]: roomId };
676
790
  await this.#sidecar.apply({ dmRooms: [...this.#dmRooms], dmRoomByUser });
677
791
  }
@@ -11,6 +11,16 @@ export default {
11
11
  'Matrix long polling and Harness replies are all working.',
12
12
  'Matrix 连接当前离线。':
13
13
  'The Matrix connection is currently offline.',
14
+ '【群聊背景】以下各条是群里其他成员之间的发言,均未指向你。':
15
+ '[Room background] The lines below are messages other members exchanged among themselves; none of them was addressed to you.',
16
+ '这些内容只用来了解现场发生过什么。请勿逐条回应、复述、翻译或总结它们,也不要因为它们而改变下面那条提问的回答。':
17
+ 'This only tells you what happened in the room. Do not answer, restate, translate or summarise these lines one by one, and do not let them change how you answer the question below.',
18
+ '—— 背景开始 ——':
19
+ '--- background begins ---',
20
+ '—— 背景结束(以上无需回应) ——':
21
+ '--- background ends (nothing above needs a reply) ---',
22
+ '【下面这条才是对你的提问,请只回答它】':
23
+ '[The line below is the question put to you: answer only this]',
14
24
  'Matrix 长轮询尚未建立,请检查 homeserver 与凭据。':
15
25
  'The Matrix long poll has not been established. Check the homeserver URL and the credentials.',
16
26
  'Matrix 正在处理消息;当前存在未恢复的连接。':