@xmanrui/dsh-im 4.38.2 → 4.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.38.2",
3
+ "version": "4.39.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -12,6 +12,9 @@ const REPLY_LABELS = Object.freeze({ zh: '\u5f15\u7528', en: 'Quoted' });
12
12
  /** Row label for a source block that names no readable field of its own. */
13
13
  const SOURCE_LABELS = Object.freeze({ zh: '\u6765\u6e90', en: 'Source' });
14
14
 
15
+ /** Row label prefix for one collected conversation-context block. */
16
+ const CONTEXT_LABELS = Object.freeze({ zh: '\u4e0a\u4e0b\u4e0b', en: 'Context' });
17
+
15
18
  /** Read the current reply-row label; an unknown language falls back to English. */
16
19
  function replyLabel() {
17
20
  try {
@@ -30,6 +33,15 @@ function sourceLabel() {
30
33
  }
31
34
  }
32
35
 
36
+ /** Read the current context-row label; unknown languages fall back. */
37
+ function contextLabel() {
38
+ try {
39
+ return getImHostLanguage() === 'en' ? CONTEXT_LABELS.en : CONTEXT_LABELS.zh;
40
+ } catch {
41
+ return CONTEXT_LABELS.en;
42
+ }
43
+ }
44
+
33
45
  /**
34
46
  * Pair every injected context block with the user message that carried it, and
35
47
  * materialize the source guidance as session-level prompt context.
@@ -85,7 +97,7 @@ export function installInjectedContext(ctx, { logger, registry = imSourceGuidanc
85
97
  }
86
98
  try {
87
99
  const rewritten = rewriteInjectedContextMessages(decision.messages, {
88
- labels: { reply: replyLabel(), source: sourceLabel() },
100
+ labels: { reply: replyLabel(), source: sourceLabel(), context: contextLabel() },
89
101
  ownedGuidance: registry.get(agent?.session?.id),
90
102
  });
91
103
  return rewritten === null ? decision : { ...decision, messages: rewritten };
@@ -250,6 +250,24 @@ export class MatrixApi {
250
250
  return await this.#request('whoami', { path: '/account/whoami', signal: options.signal });
251
251
  }
252
252
 
253
+ /**
254
+ * Reads one member's display name from the room state. The Matrix profile
255
+ * field is optional, so a homeserver without the member or without a name
256
+ * reads as null rather than an error.
257
+ * @param roomId - the room whose state to read.
258
+ * @param userId - the member's fully-qualified Matrix user id.
259
+ * @param options - `{ signal }` forwarding options.
260
+ * @returns {Promise<string|null>} the trimmed display name, or null when absent.
261
+ */
262
+ async getRoomMemberDisplayName(roomId, userId, options = {}) {
263
+ const event = await this.#request(`room member ${userId}`, {
264
+ path: `/rooms/${encodeURIComponent(roomId)}/state/members/${encodeURIComponent(userId)}`,
265
+ signal: options.signal,
266
+ }).catch(() => null);
267
+ const name = cleanString(event?.content?.displayname);
268
+ return name && name.length <= 256 ? name : null;
269
+ }
270
+
253
271
  async login({ identifier, password, deviceId, displayName }, options = {}) {
254
272
  const body = {
255
273
  type: 'm.login.password',
@@ -336,6 +354,22 @@ export class MatrixApi {
336
354
  });
337
355
  }
338
356
 
357
+ /**
358
+ * Single-event lookup used by the conversation context collectors to resolve a
359
+ * reply or thread root outside the retained sync window. The homeserver answers
360
+ * 404 for an unknown event, so a miss travels as null instead of an exception;
361
+ * an E2EE room returns the still-encrypted event and the caller decrypts it.
362
+ */
363
+ async getEvent(roomId, eventId, options = {}) {
364
+ if (!isMatrixRoomId(roomId) || !isMatrixEventId(eventId)) throw new TypeError('Matrix event lookup target is invalid');
365
+ return await this.#request('event', {
366
+ path: `/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`,
367
+ timeoutMs: 10_000,
368
+ signal: options.signal,
369
+ }).catch(() => null);
370
+ }
371
+
372
+
339
373
  async listRelations(roomId, eventId, relationType, options = {}) {
340
374
  if (!isMatrixRoomId(roomId) || !isMatrixEventId(eventId)) throw new TypeError('Matrix relation query target is invalid');
341
375
  if (!/^[m]?[A-Za-z0-9._-]{1,128}$/.test(String(relationType ?? ''))) throw new TypeError('Matrix relation type is invalid');
@@ -16,6 +16,18 @@ const MAX_MEDIA_BYTES_CEILING = 104_857_600;
16
16
  const ROOM_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 1, max: 500 });
17
17
  const ROOM_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
18
18
  const ROOM_CONTEXT_TZ_BOUNDS = Object.freeze({ min: -840, max: 840 });
19
+ const CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
20
+ const REPLY_DEPTH_BOUNDS = Object.freeze({ min: 1, max: 10 });
21
+ const AMBIENT_GAP_BOUNDS = Object.freeze({ min: 5_000, max: 3_600_000 });
22
+ const AMBIENT_WINDOW_BOUNDS = Object.freeze({ min: 10_000, max: 86_400_000 });
23
+ const AMBIENT_COUNT_BOUNDS = Object.freeze({ min: 1, max: 200 });
24
+ const VISIBLE_CONTEXT_WINDOW_BOUNDS = Object.freeze({ min: 0, max: 86_400_000 });
25
+ const VISIBLE_CONTEXT_LIMIT_BOUNDS = Object.freeze({ min: 0, max: 500 });
26
+ const VISIBLE_CONTEXT_CHARS_BOUNDS = Object.freeze({ min: 200, max: 200_000 });
27
+ const COALESCE_WINDOW_BOUNDS = Object.freeze({ min: 0, max: 60_000 });
28
+ const AUTO_STEER_LIMIT_BOUNDS = Object.freeze({ min: 0, max: 100 });
29
+ const THREADS_MODES = Object.freeze(['inherit', 'isolate', 'auto']);
30
+ const TRIGGER_ON_REPLY_MODES = Object.freeze(['any', 'bot-only', 'never']);
19
31
 
20
32
  function cleanString(value) {
21
33
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -185,12 +197,31 @@ export class MatrixConfigStore {
185
197
  const roomContextLimit = boundedInteger(value.roomContextLimit, ROOM_CONTEXT_LIMIT_BOUNDS);
186
198
  const roomContextMaxChars = boundedInteger(value.roomContextMaxChars, ROOM_CONTEXT_CHARS_BOUNDS);
187
199
  const roomContextTzOffsetMinutes = boundedInteger(value.roomContextTzOffsetMinutes, ROOM_CONTEXT_TZ_BOUNDS);
200
+ const threadsMode = cleanString(value.threadsMode) ?? 'inherit';
201
+ const triggerOnReply = cleanString(value.triggerOnReply) ?? 'never';
202
+ const contextMaxChars = boundedInteger(value.contextMaxChars, CONTEXT_CHARS_BOUNDS);
203
+ const replyMaxDepth = boundedInteger(value.replyMaxDepth, REPLY_DEPTH_BOUNDS);
204
+ const ambientBurstGapMs = boundedInteger(value.ambientBurstGapMs, AMBIENT_GAP_BOUNDS);
205
+ const ambientRecentWindowMs = boundedInteger(value.ambientRecentWindowMs, AMBIENT_WINDOW_BOUNDS);
206
+ const ambientRecentCount = boundedInteger(value.ambientRecentCount, AMBIENT_COUNT_BOUNDS);
207
+ const visibleContextWindowMs = boundedInteger(value.visibleContextWindowMs, VISIBLE_CONTEXT_WINDOW_BOUNDS);
208
+ const visibleContextLimit = boundedInteger(value.visibleContextLimit, VISIBLE_CONTEXT_LIMIT_BOUNDS);
209
+ const visibleContextChars = boundedInteger(value.visibleContextChars, VISIBLE_CONTEXT_CHARS_BOUNDS);
210
+ const coalesceWindowMs = boundedInteger(value.coalesceWindowMs, COALESCE_WINDOW_BOUNDS);
211
+ const autoSteerLimit = boundedInteger(value.autoSteerLimit, AUTO_STEER_LIMIT_BOUNDS);
188
212
  if (!E2EE_MODES.has(e2eeMode) || !AUTO_JOIN_MODES.has(autoJoinInvites)
189
213
  || maxMessageLength === null || maxMediaBytes === null
190
214
  || allowedRooms === null || freeResponseRooms === null || ignoreUserPatterns === null
191
215
  || roomContextLimit === null || roomContextMaxChars === null
192
- || roomContextTzOffsetMinutes === null) return null;
193
- for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled']) {
216
+ || roomContextTzOffsetMinutes === null
217
+ || !THREADS_MODES.includes(threadsMode) || !TRIGGER_ON_REPLY_MODES.includes(triggerOnReply)
218
+ || contextMaxChars === null || replyMaxDepth === null
219
+ || ambientBurstGapMs === null || ambientRecentWindowMs === null || ambientRecentCount === null
220
+ || visibleContextWindowMs === null || visibleContextLimit === null || visibleContextChars === null
221
+ || coalesceWindowMs === null || autoSteerLimit === null) return null;
222
+ for (const key of ['requireMention', 'processNotices', 'allowRoomMentions', 'reactions', 'roomContextEnabled',
223
+ 'rootContextOnceEnabled', 'replyContextEnabled', 'ambientContextEnabled',
224
+ 'visibleContextEnabled', 'autoSteerSameSender']) {
194
225
  if (value?.[key] !== undefined && typeof value[key] !== 'boolean') return null;
195
226
  }
196
227
  return Object.freeze({
@@ -220,6 +251,23 @@ export class MatrixConfigStore {
220
251
  ...(roomContextLimit !== undefined ? { roomContextLimit } : {}),
221
252
  ...(roomContextMaxChars !== undefined ? { roomContextMaxChars } : {}),
222
253
  ...(roomContextTzOffsetMinutes !== undefined ? { roomContextTzOffsetMinutes } : {}),
254
+ ...(threadsMode !== 'inherit' ? { threadsMode } : {}),
255
+ ...(triggerOnReply !== 'never' ? { triggerOnReply } : {}),
256
+ ...(typeof value.rootContextOnceEnabled === 'boolean' ? { rootContextOnceEnabled: value.rootContextOnceEnabled } : {}),
257
+ ...(typeof value.replyContextEnabled === 'boolean' ? { replyContextEnabled: value.replyContextEnabled } : {}),
258
+ ...(typeof value.ambientContextEnabled === 'boolean' ? { ambientContextEnabled: value.ambientContextEnabled } : {}),
259
+ ...(contextMaxChars !== undefined ? { contextMaxChars } : {}),
260
+ ...(replyMaxDepth !== undefined ? { replyMaxDepth } : {}),
261
+ ...(ambientBurstGapMs !== undefined ? { ambientBurstGapMs } : {}),
262
+ ...(ambientRecentWindowMs !== undefined ? { ambientRecentWindowMs } : {}),
263
+ ...(ambientRecentCount !== undefined ? { ambientRecentCount } : {}),
264
+ ...(typeof value.visibleContextEnabled === 'boolean' ? { visibleContextEnabled: value.visibleContextEnabled } : {}),
265
+ ...(visibleContextWindowMs !== undefined ? { visibleContextWindowMs } : {}),
266
+ ...(visibleContextLimit !== undefined ? { visibleContextLimit } : {}),
267
+ ...(visibleContextChars !== undefined ? { visibleContextChars } : {}),
268
+ ...(coalesceWindowMs !== undefined ? { coalesceWindowMs } : {}),
269
+ ...(typeof value.autoSteerSameSender === 'boolean' ? { autoSteerSameSender: value.autoSteerSameSender } : {}),
270
+ ...(autoSteerLimit !== undefined ? { autoSteerLimit } : {}),
223
271
  });
224
272
  }
225
273
 
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { isMatrixEventId, isMatrixRoomId, isMatrixUserId } from './matrix-api.mjs';
10
+ import { buildConversationRoute, encodeConversationRouteKey } from '../shared/conversation/route.mjs';
11
+ import { evaluateTrigger } from '../shared/conversation/trigger-policy.mjs';
10
12
 
11
13
  const GRACE_MS = 5_000;
12
14
  const SKEW_OBSERVE_AFTER_MS = 30_000;
@@ -219,7 +221,8 @@ function eventOriginTs(event) {
219
221
  return Number.isSafeInteger(ts) && ts > 0 ? (ts < 1e12 ? ts * 1_000 : ts) : null;
220
222
  }
221
223
 
222
- function threadIdOf(content) {
224
+ /** The thread root a timeline event belongs to, from its MSC3402 relations. */
225
+ export function threadIdOf(content) {
223
226
  const relates = content?.['m.relates_to'];
224
227
  if (!relates || typeof relates !== 'object') return null;
225
228
  const chain = Array.isArray(relates.chain) ? relates.chain : [];
@@ -228,12 +231,39 @@ function threadIdOf(content) {
228
231
  return eventId && isMatrixEventId(eventId) ? eventId : null;
229
232
  }
230
233
 
231
- function replyEventIdOf(content) {
232
- const inReplyTo = content?.['m.relates_to']?.['m.in_reply_to'];
233
- const eventId = typeof inReplyTo?.event_id === 'string' ? inReplyTo.event_id : null;
234
+ function cleanEventId(value) {
235
+ const eventId = typeof value === 'string' ? value.trim() : '';
234
236
  return eventId && isMatrixEventId(eventId) ? eventId : null;
235
237
  }
236
238
 
239
+ /**
240
+ * The target of a genuine reply relation, in either relation form the Matrix
241
+ * spec permits: the new form names it under `m.relates_to` with
242
+ * `rel_type: "m.reply"`, the legacy form under the top-level `m.in_reply_to`,
243
+ * and this plugin's own writer nests it under `m.relates_to` for the events it
244
+ * sends itself. The `is_falling_back` marker — at `m.relates_to`, at the
245
+ * legacy `m.in_reply_to`, or on the nested form — demotes the declaration to
246
+ * the thread-membership statement clients attach automatically when sending
247
+ * inside a thread; per the spec that is not a human reply, so it must not
248
+ * drive reply context collection, reply classification, recorded reply targets,
249
+ * or the thread fork decision.
250
+ */
251
+ export function replyEventIdOf(content) {
252
+ const relates = content?.['m.relates_to'];
253
+ const legacy = content?.['m.in_reply_to'];
254
+ const nested = relates?.['m.in_reply_to'];
255
+ if (relates?.is_falling_back === true || legacy?.is_falling_back === true || nested?.is_falling_back === true) return null;
256
+ if (relates?.rel_type === 'm.reply') {
257
+ const target = cleanEventId(relates.event_id);
258
+ if (target) return target;
259
+ }
260
+ for (const candidate of [legacy?.event_id, nested?.event_id]) {
261
+ const target = cleanEventId(candidate);
262
+ if (target) return target;
263
+ }
264
+ return null;
265
+ }
266
+
237
267
  /**
238
268
  * Project one timeline event into the normalized bridge message, or into a
239
269
  * typed drop reason. The order of gates is fixed: self, bridge/system,
@@ -281,10 +311,28 @@ export function normalizeMatrixTimelineEvent({
281
311
  const rawText = typeof content.body === 'string' ? content.body : '';
282
312
  const mentioned = detectMatrixMention(content, botUserId);
283
313
  const commandLike = isMatrixCommandLike(rawText);
284
- const requiresMention = kind === 'group'
285
- && config.requireMention !== false
286
- && !(config.freeResponseRooms instanceof Set && config.freeResponseRooms.has(roomId));
287
- if (requiresMention && !mentioned && !commandLike && replyToEventId === null) return { drop: 'mention-required' };
314
+ // One table decides the mention gate and the addressed bit, shared with other
315
+ // channels through the trigger policy. `triggerOnReply` defaults to 'never' —
316
+ // a room message wakes the bot only through an @-mention or a command, so an
317
+ // unaddressed reply on the room timeline or in a thread stays a recorded fact.
318
+ // 'any' restores the 4.25-era shape where every reply relation addresses the
319
+ // bot; 'bot-only' requires the channel's classifier (usually the bot-event
320
+ // binding table) to prove the reply answers one of the bot's own messages.
321
+ const replyTargetKind = kind === 'group' && replyToEventId !== null
322
+ && typeof deps.classifyReplyTarget === 'function'
323
+ ? deps.classifyReplyTarget(replyToEventId) ?? null
324
+ : null;
325
+ const gate = evaluateTrigger({
326
+ kind,
327
+ requireMention: config.requireMention,
328
+ freeResponse: config.freeResponseRooms instanceof Set && config.freeResponseRooms.has(roomId),
329
+ mentioned,
330
+ commandLike,
331
+ replyToPresent: replyToEventId !== null,
332
+ replyTargetKind,
333
+ replyMode: config.triggerOnReply,
334
+ });
335
+ if (!gate.pass) return { drop: gate.reason };
288
336
 
289
337
  const text = stripMatrixMentions(rawText, botUserId);
290
338
  const media = MEDIA_MSGTYPES.has(msgtype)
@@ -293,9 +341,42 @@ export function normalizeMatrixTimelineEvent({
293
341
  if (MEDIA_MSGTYPES.has(msgtype) && !media) return { drop: 'media-unusable' };
294
342
  if (msgtype !== 'm.text' && msgtype !== 'm.notice' && !MEDIA_MSGTYPES.has(msgtype)) return { drop: 'type' };
295
343
 
296
- const conversationId = kind === 'direct'
297
- ? `dm:${senderId.toLowerCase()}`
298
- : `room:${roomId}${threadId ? `$${threadId}` : ''}`;
344
+ // Thread conversation fork policy. A genuine reply whose thread root equals
345
+ // its reply target says "answer this message" — the client started a thread at
346
+ // the replied-to event — so it inherits the conversation its target belongs to
347
+ // (the room's main conversation) instead of forking a fresh session. The
348
+ // thread-membership declaration clients attach automatically (a falling-back
349
+ // relation) is not a human reply and never selects a conversation: posts made
350
+ // inside a thread fork that thread's own conversation. A reply to a root the
351
+ // bot itself authored does the same — the panel under a bot answer is read as
352
+ // its own message list, not as a continuation of the mainline turn the answer
353
+ // once replied to. `isolate` restores fork-always; `auto` prefers the
354
+ // bot-event binding table when the channel supplies one for the reply target.
355
+ const threadsMode = config.threadsMode === 'isolate' || config.threadsMode === 'auto'
356
+ ? config.threadsMode
357
+ : 'inherit';
358
+ const rootIsBotAuthored = kind === 'group' && threadId !== null
359
+ && ((typeof deps.classifyReplyTarget === 'function' && deps.classifyReplyTarget(threadId) === 'bot')
360
+ || (typeof deps.isBotRoomEvent === 'function' && deps.isBotRoomEvent(roomId, threadId) === true));
361
+ const inheritsThreadTarget = kind === 'group' && threadId !== null
362
+ && threadsMode !== 'isolate' && replyToEventId === threadId && !rootIsBotAuthored;
363
+ const routeThreadId = kind === 'group' && threadId !== null && !inheritsThreadTarget ? threadId : null;
364
+ const boundRouteKey = threadsMode === 'auto' && kind === 'group' && replyToEventId !== null
365
+ && typeof deps.routeOverrideForReply === 'function'
366
+ ? deps.routeOverrideForReply(replyToEventId)
367
+ : null;
368
+ const routeKey = typeof boundRouteKey === 'string' && boundRouteKey
369
+ ? boundRouteKey
370
+ : encodeConversationRouteKey(buildConversationRoute({
371
+ channel: 'matrix',
372
+ botId: config.botId ?? null,
373
+ chatId: roomId,
374
+ senderId,
375
+ threadId: routeThreadId,
376
+ kind,
377
+ }));
378
+ const keyPrefix = kind === 'direct' ? 'direct:' : 'group:';
379
+ const conversationId = routeKey.startsWith(keyPrefix) ? routeKey.slice(keyPrefix.length) : routeKey;
299
380
 
300
381
  return {
301
382
  message: {
@@ -310,7 +391,7 @@ export function normalizeMatrixTimelineEvent({
310
391
  plainText: msgtype === 'm.text' && content.format !== 'org.matrix.custom.html',
311
392
  images: media?.images ?? [],
312
393
  files: media?.files ?? [],
313
- addressed: kind === 'direct' || mentioned || commandLike || replyToEventId !== null,
394
+ addressed: gate.addressed,
314
395
  mentioned,
315
396
  threadId,
316
397
  replyToEventId,
@@ -352,6 +433,17 @@ export function normalizeMatrixDeliveryTarget({ kind, route } = {}) {
352
433
 
353
434
  export function matrixConversationKeyFromTarget(target) {
354
435
  if (!target) return null;
355
- if (target.kind === 'dm') return `dm:${target.userId.toLowerCase()}`;
356
- return `room:${target.roomId}${'threadId' in target && target.threadId ? `$${target.threadId}` : ''}`;
436
+ if (target.kind === 'dm') {
437
+ return encodeConversationRouteKey(buildConversationRoute({
438
+ channel: 'matrix',
439
+ chatId: target.userId,
440
+ senderId: target.userId,
441
+ kind: 'dm',
442
+ })).slice('direct:'.length);
443
+ }
444
+ return encodeConversationRouteKey(buildConversationRoute({
445
+ channel: 'matrix',
446
+ chatId: target.roomId,
447
+ threadId: 'threadId' in target ? target.threadId : null,
448
+ })).slice('group:'.length);
357
449
  }