@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.
- package/lib/client.js +3 -1
- package/lib/index.js +279 -273
- package/package.json +1 -1
- package/plugin-src/client/i18n.js +1 -0
- package/plugin-src/client/update-panel.js +1 -0
- package/plugin-src/host/channels/matrix/production.mjs +14 -0
- package/src/channels/feishu/state-store.mjs +3 -2
- package/src/channels/matrix/matrix-config-store.mjs +14 -2
- package/src/channels/matrix/matrix-room-history.mjs +316 -0
- package/src/channels/matrix/matrix-runtime.mjs +134 -20
- package/src/channels/shared/i18n-en/matrix.mjs +10 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 正在处理消息;当前存在未恢复的连接。':
|