@xmanrui/dsh-im 4.4.0 → 4.6.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 (43) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +29 -9
  4. package/lib/index.js +252 -241
  5. package/package.json +1 -1
  6. package/plugin-src/client/context-enhancement.js +19 -5
  7. package/plugin-src/client/i18n.js +5 -1
  8. package/plugin-src/host/modern-harness-api.mjs +3 -0
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
  10. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  11. package/src/channels/dingtalk/state-store.mjs +98 -0
  12. package/src/channels/discord/discord-api.mjs +7 -0
  13. package/src/channels/discord/discord-runtime.mjs +97 -2
  14. package/src/channels/feishu/bridge.mjs +30 -7
  15. package/src/channels/feishu/feishu-cards.mjs +2 -2
  16. package/src/channels/feishu/feishu-channel.mjs +36 -6
  17. package/src/channels/feishu/message-utils.mjs +229 -0
  18. package/src/channels/qq/qq-bridge.mjs +56 -9
  19. package/src/channels/shared/batch-input.mjs +3 -3
  20. package/src/channels/shared/bot-workspace-store.mjs +5 -0
  21. package/src/channels/shared/context-enhancement.mjs +9 -4
  22. package/src/channels/shared/harness-client.mjs +88 -30
  23. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  24. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  25. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  26. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  27. package/src/channels/shared/image-prompt.mjs +51 -0
  28. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  29. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  30. package/src/channels/shared/session-title.mjs +74 -0
  31. package/src/channels/shared/text-harness-bridge.mjs +19 -8
  32. package/src/channels/shared/workspace-command.mjs +16 -4
  33. package/src/channels/shared/workspace-session.mjs +28 -2
  34. package/src/channels/slack/manifest.mjs +3 -0
  35. package/src/channels/slack/slack-api.mjs +18 -0
  36. package/src/channels/slack/slack-runtime.mjs +56 -0
  37. package/src/channels/telegram/telegram-runtime.mjs +118 -2
  38. package/src/channels/wecom/wecom-bridge.mjs +55 -9
  39. package/src/channels/weixin/state-store.mjs +110 -0
  40. package/src/channels/weixin/weixin-api.mjs +86 -2
  41. package/src/channels/weixin/weixin-bridge.mjs +104 -12
  42. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  43. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -2,9 +2,11 @@ import {
2
2
  DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
3
3
  extractWeixinFiles,
4
4
  extractWeixinImages,
5
+ extractWeixinReplyReference,
5
6
  extractWeixinText,
6
7
  splitWeixinText,
7
8
  weixinMessageId,
9
+ weixinMessageTimestampMs,
8
10
  } from './weixin-api.mjs';
9
11
  import {
10
12
  harnessAnswerForQuestion,
@@ -38,7 +40,6 @@ import {
38
40
  hasInboundImages,
39
41
  imagePromptDiagnostic,
40
42
  imagePromptUserMessage,
41
- promptContentForMessage,
42
43
  } from '../shared/image-prompt.mjs';
43
44
  import {
44
45
  hasInboundFiles,
@@ -47,10 +48,15 @@ import {
47
48
  } from '../shared/inbound-file.mjs';
48
49
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
49
50
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
51
+ import {
52
+ hasReplyReference,
53
+ promptContentForInboundMessage,
54
+ } from '../shared/semantic/reply-reference.mjs';
50
55
  import {
51
56
  createDeliveryReceipt,
52
57
  providerMessageIdsFor,
53
58
  } from '../shared/semantic/delivery.mjs';
59
+ import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
54
60
  import {
55
61
  channelDeliveryFailure,
56
62
  clearLastMessageFailure,
@@ -67,6 +73,10 @@ const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处
67
73
  const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
68
74
  const TYPING_RETRY_DELAY_MS = 60_000;
69
75
  const WEIXIN_SEND_DIAGNOSTIC = Symbol('weixin-send-diagnostic');
76
+ const WEIXIN_REPLY_HISTORY_PAGE_SIZE = 100;
77
+ const WEIXIN_REPLY_HISTORY_MAX_PAGES = 3;
78
+ const WEIXIN_REPLY_HISTORY_TIMEOUT_MS = 5_000;
79
+ const WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS = 15_000;
70
80
 
71
81
  const HELP_TEXT = () => [
72
82
  t('微信已连接 DeepSeek Harness。'),
@@ -75,7 +85,7 @@ const HELP_TEXT = () => [
75
85
  t('/new 开启一个全新会话'),
76
86
  t('/compact 压缩当前会话的较早上下文'),
77
87
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
78
- t('/workspace 工作区绝对路径 切换工作区'),
88
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
79
89
  t('/workspacelist 列出工作区绝对路径'),
80
90
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
81
91
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -182,7 +192,20 @@ function weixinSendFailureOptions(error) {
182
192
  return undefined;
183
193
  }
184
194
 
185
- export function weixinInboundMessage(message, api) {
195
+ export function weixinInboundMessage(message, api, state, { loadReplyContent } = {}) {
196
+ const toUserId = nonEmptyString(message?.from_user_id);
197
+ const replyTo = extractWeixinReplyReference(message, {
198
+ resolveContent: (reference) => state?.recentOutboundTextFor?.({
199
+ toUserId,
200
+ ...reference,
201
+ }),
202
+ ...(typeof loadReplyContent === 'function' ? {
203
+ loadContent: (reference, options) => loadReplyContent({
204
+ toUserId,
205
+ ...reference,
206
+ }, options),
207
+ } : {}),
208
+ });
186
209
  return {
187
210
  content: extractWeixinText(message) ?? '',
188
211
  images: typeof api?.inboundImages === 'function'
@@ -191,6 +214,7 @@ export function weixinInboundMessage(message, api) {
191
214
  files: typeof api?.inboundFiles === 'function'
192
215
  ? api.inboundFiles(message)
193
216
  : extractWeixinFiles(message),
217
+ ...(replyTo ? { replyTo } : {}),
194
218
  };
195
219
  }
196
220
 
@@ -375,7 +399,8 @@ export class WeixinHarnessBridge {
375
399
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
376
400
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
377
401
  : this.#batchInputs.handle(key, commandText, {
378
- plainText: isNativeWeixinText(message),
402
+ plainText: isNativeWeixinText(message)
403
+ && !extractWeixinReplyReference(message),
379
404
  });
380
405
  if (result.handled) {
381
406
  if (result.kind === 'submit') {
@@ -489,7 +514,7 @@ export class WeixinHarnessBridge {
489
514
  batchSubmission = null,
490
515
  } = {}) {
491
516
  const preparedMessage = prefetchInboundFiles(
492
- weixinInboundMessage(message, this.#api),
517
+ this.#inboundMessage(message),
493
518
  { signal: this.#signal },
494
519
  );
495
520
  const previous = this.#queues.get(key) ?? Promise.resolve();
@@ -508,6 +533,54 @@ export class WeixinHarnessBridge {
508
533
  return current;
509
534
  }
510
535
 
536
+ #inboundMessage(message) {
537
+ return weixinInboundMessage(message, this.#api, this.#state, {
538
+ loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
539
+ });
540
+ }
541
+
542
+ async #loadReplyContent(reference, { signal: callerSignal } = {}) {
543
+ const indexed = this.#state.recentOutboundTextFor?.(reference);
544
+ if (indexed) return { content: indexed };
545
+ const quotedAt = [
546
+ weixinMessageTimestampMs(reference?.messageId),
547
+ Number(reference?.createTimeMs),
548
+ Number(reference?.updateTimeMs),
549
+ ].find(Number.isSafeInteger);
550
+ if (quotedAt === undefined) return { unavailableReason: 'not-delivered' };
551
+ const sender = nonEmptyString(reference?.toUserId);
552
+ const sessionId = sender ? this.#state.sessionFor(conversationKey(sender)) : null;
553
+ const session = typeof sessionId === 'string' && sessionId
554
+ ? this.#harness.workspaceSession?.(sessionId)
555
+ : null;
556
+ if (typeof session?.readHistory !== 'function') {
557
+ return { unavailableReason: 'not-delivered' };
558
+ }
559
+ const text = await recoverAssistantTextByTimestamp({
560
+ session,
561
+ quotedAt,
562
+ signal: callerSignal,
563
+ pageSize: WEIXIN_REPLY_HISTORY_PAGE_SIZE,
564
+ maxPages: WEIXIN_REPLY_HISTORY_MAX_PAGES,
565
+ timeoutMs: WEIXIN_REPLY_HISTORY_TIMEOUT_MS,
566
+ toleranceMs: WEIXIN_REPLY_HISTORY_MATCH_TOLERANCE_MS,
567
+ });
568
+ if (!text) return { unavailableReason: 'not-delivered' };
569
+ const messageId = nonEmptyString(reference?.messageId);
570
+ try {
571
+ await this.#state.rememberOutboundMessage?.({
572
+ toUserId: sender,
573
+ text,
574
+ sentAt: quotedAt,
575
+ completedAt: quotedAt,
576
+ providerMessageIds: messageId ? [messageId] : [],
577
+ });
578
+ } catch (error) {
579
+ this.#logger.warn?.('[dsh-weixin] failed to remember a recovered quote:', error);
580
+ }
581
+ return { content: text };
582
+ }
583
+
511
584
  #finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
512
585
  let task;
513
586
  task = Promise.resolve().then(async () => {
@@ -644,11 +717,12 @@ export class WeixinHarnessBridge {
644
717
  let batchSettled = batchSubmission === null;
645
718
  let promptRecorded = false;
646
719
  try {
647
- const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
720
+ const promptMessage = preparedMessage ?? this.#inboundMessage(message);
648
721
  const text = promptMessage.content;
649
722
  const hasImages = hasInboundImages(promptMessage);
650
723
  const hasFiles = hasInboundFiles(promptMessage);
651
- if (!text && !hasImages && !hasFiles) {
724
+ const hasReply = hasReplyReference(promptMessage);
725
+ if (!text && !hasImages && !hasFiles && !hasReply) {
652
726
  await this.#send(sender, t('目前支持文字、图片、文件,以及微信已转成文字的语音消息。'), contextToken, runId);
653
727
  await this.#state.markSeen(messageId);
654
728
  return;
@@ -701,15 +775,18 @@ export class WeixinHarnessBridge {
701
775
  let artifacts = [];
702
776
  await this.#startTyping(sender, contextToken);
703
777
  try {
704
- let content = hasImages
705
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
778
+ let content = hasImages || hasReply
779
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
706
780
  : undefined;
707
781
  const snapshot = this.#acceptedMessageIds.get(messageId);
782
+ let contextEnhanced = false;
708
783
  if (snapshot) {
709
- content = enhanceContextContent(content ?? text, snapshot, () => ({
784
+ const originalContent = content ?? text;
785
+ content = enhanceContextContent(originalContent, snapshot, () => ({
710
786
  channel: 'weixin',
711
787
  senderId: sender,
712
788
  }));
789
+ contextEnhanced = content !== originalContent;
713
790
  }
714
791
  await this.#state.markSeen(messageId);
715
792
  promptRecorded = true;
@@ -717,7 +794,9 @@ export class WeixinHarnessBridge {
717
794
  harness: this.#harness,
718
795
  state: this.#state,
719
796
  key,
720
- ...(content !== undefined ? { content } : { text }),
797
+ text,
798
+ content,
799
+ contextEnhanced,
721
800
  createOptions: { signal: this.#signal },
722
801
  existsOptions: { signal: this.#signal },
723
802
  askOptions: {
@@ -1149,6 +1228,7 @@ export class WeixinHarnessBridge {
1149
1228
  const chunks = splitWeixinText(text, this.#maxMessageChars);
1150
1229
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
1151
1230
  const chunk = chunks[chunkIndex];
1231
+ const sentAt = Date.now();
1152
1232
  try {
1153
1233
  const result = await this.#api.sendText({
1154
1234
  baseUrl: this.#baseUrl,
@@ -1159,7 +1239,19 @@ export class WeixinHarnessBridge {
1159
1239
  runId,
1160
1240
  signal: this.#signal,
1161
1241
  });
1162
- providerMessageIds.push(...providerMessageIdsFor(result));
1242
+ const chunkMessageIds = providerMessageIdsFor(result);
1243
+ providerMessageIds.push(...chunkMessageIds);
1244
+ try {
1245
+ await this.#state.rememberOutboundMessage?.({
1246
+ toUserId,
1247
+ text: chunk,
1248
+ sentAt,
1249
+ completedAt: Date.now(),
1250
+ providerMessageIds: chunkMessageIds,
1251
+ });
1252
+ } catch (error) {
1253
+ this.#logger.warn?.('[dsh-weixin] failed to remember an outbound message:', error);
1254
+ }
1163
1255
  } catch (error) {
1164
1256
  throw weixinSendError(error, {
1165
1257
  baseUrl: this.#baseUrl,
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
2
2
  import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
3
+ import { providerMessageIdsFor } from '../shared/semantic/delivery.mjs';
3
4
  import {
4
5
  connectionTestTarget,
5
6
  connectionTestTargetUnavailable,
@@ -324,9 +325,7 @@ export class WeixinRuntime {
324
325
  if (!this.#status.ready || !this.#abortController) {
325
326
  throw new Error('Weixin runtime is not connected');
326
327
  }
327
- await this.#api.sendText({
328
- baseUrl: this.#config.baseUrl,
329
- token: this.#token,
328
+ await this.#sendTrackedText({
330
329
  toUserId,
331
330
  text,
332
331
  signal: this.#abortController.signal,
@@ -334,6 +333,29 @@ export class WeixinRuntime {
334
333
  return { sent: true };
335
334
  }
336
335
 
336
+ async #sendTrackedText({ toUserId, text, signal }) {
337
+ const sentAt = Date.now();
338
+ const result = await this.#api.sendText({
339
+ baseUrl: this.#config.baseUrl,
340
+ token: this.#token,
341
+ toUserId,
342
+ text,
343
+ signal,
344
+ });
345
+ try {
346
+ await this.#state.rememberOutboundMessage?.({
347
+ toUserId,
348
+ text,
349
+ sentAt,
350
+ completedAt: Date.now(),
351
+ providerMessageIds: providerMessageIdsFor(result),
352
+ });
353
+ } catch (error) {
354
+ this.#logger.warn?.('[dsh-weixin] failed to remember an outbound message:', error);
355
+ }
356
+ return result;
357
+ }
358
+
337
359
  async sendProactiveText(target, text, { signal } = {}) {
338
360
  const toUserId = typeof target?.route?.toUserId === 'string'
339
361
  ? target.route.toUserId.trim() : '';
@@ -348,9 +370,7 @@ export class WeixinRuntime {
348
370
  throw error;
349
371
  }
350
372
  signal?.throwIfAborted();
351
- await this.#api.sendText({
352
- baseUrl: this.#config.baseUrl,
353
- token: this.#token,
373
+ await this.#sendTrackedText({
354
374
  toUserId,
355
375
  text,
356
376
  signal: signal ?? this.#abortController.signal,
@@ -96,6 +96,60 @@ function messageText(content) {
96
96
  ?? '';
97
97
  }
98
98
 
99
+ function whatsappReplyAttachment(kind, media, fallbackName) {
100
+ if (!media || typeof media !== 'object') return null;
101
+ const name = typeof media.fileName === 'string' && media.fileName
102
+ ? media.fileName : typeof fallbackName === 'string' && fallbackName
103
+ ? fallbackName : undefined;
104
+ return { kind, ...(name ? { name } : {}) };
105
+ }
106
+
107
+ function whatsappReplyAttachments(content) {
108
+ const attachments = [];
109
+ if (content?.imageMessage) {
110
+ attachments.push(whatsappReplyAttachment('image', content.imageMessage));
111
+ }
112
+ if (content?.documentMessage) {
113
+ const mediaType = typeof content.documentMessage.mimetype === 'string'
114
+ ? content.documentMessage.mimetype.toLowerCase() : '';
115
+ attachments.push(whatsappReplyAttachment(
116
+ mediaType.startsWith('image/') ? 'image' : 'file',
117
+ content.documentMessage,
118
+ ));
119
+ }
120
+ if (content?.audioMessage) {
121
+ attachments.push(whatsappReplyAttachment('audio', content.audioMessage));
122
+ }
123
+ if (content?.videoMessage) {
124
+ attachments.push(whatsappReplyAttachment('video', content.videoMessage));
125
+ }
126
+ if (content?.stickerMessage) {
127
+ const mediaType = typeof content.stickerMessage.mimetype === 'string'
128
+ ? content.stickerMessage.mimetype.toLowerCase() : '';
129
+ attachments.push(whatsappReplyAttachment(
130
+ mediaType.startsWith('video/') || content.stickerMessage.isAnimated === true
131
+ ? 'video' : 'image',
132
+ content.stickerMessage,
133
+ ));
134
+ }
135
+ return attachments.filter(Boolean);
136
+ }
137
+
138
+ function whatsappReplyReference(context) {
139
+ if (!context?.quotedMessage || typeof context.quotedMessage !== 'object') return undefined;
140
+ const content = normalizeMessageContent(context.quotedMessage);
141
+ const messageId = typeof context.stanzaId === 'string' && context.stanzaId
142
+ ? context.stanzaId : undefined;
143
+ const authorId = typeof context.participant === 'string' && context.participant
144
+ ? context.participant : undefined;
145
+ return {
146
+ ...(messageId ? { messageId } : {}),
147
+ ...(authorId ? { authorId } : {}),
148
+ content: messageText(content),
149
+ attachments: whatsappReplyAttachments(content),
150
+ };
151
+ }
152
+
99
153
  function mediaSize(value) {
100
154
  if (Number.isSafeInteger(value) && value >= 0) return value;
101
155
  let converted;
@@ -260,6 +314,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
260
314
  && areJidsSameUser(context.participant, accountJid);
261
315
  const image = whatsappImageSource(message, content, download, { viewOnce });
262
316
  const file = whatsappFileSource(message, content, download);
317
+ const replyTo = whatsappReplyReference(context);
263
318
  return {
264
319
  messageId: `${remoteJid}:${messageId}`,
265
320
  providerMessageId: messageId,
@@ -274,6 +329,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
274
329
  || typeof content?.extendedTextMessage?.text === 'string',
275
330
  images: image ? [image] : [],
276
331
  files: file ? [file] : [],
332
+ ...(replyTo ? { replyTo } : {}),
277
333
  addressed: !group || fromMe || mentioned || replyToSelf,
278
334
  selfChat,
279
335
  replyTarget: { jid: remoteJid, quoted: message, selfChat },