@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -16,15 +16,28 @@ const FIELD_LABELS = Object.freeze({
16
16
  conversationType: '会话类型',
17
17
  senderId: '发送者标识',
18
18
  senderName: '发送者昵称',
19
+ conversationTitle: '会话标题',
19
20
  botId: '机器人标识',
20
21
  });
21
22
 
23
+ const FIELD_HELP = Object.freeze({
24
+ senderName: Object.freeze({
25
+ labelKey: 'senderNameHelpLabel',
26
+ text: '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。',
27
+ }),
28
+ conversationTitle: Object.freeze({
29
+ labelKey: 'conversationTitleHelpLabel',
30
+ text: '该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。',
31
+ }),
32
+ });
33
+
22
34
  const SCOPE_COPY = Object.freeze({
23
35
  group: Object.freeze({
24
36
  title: '群聊',
25
37
  enable: '启用',
26
38
  fieldsHelpLabel: '查看群聊来源字段说明',
27
39
  senderNameHelpLabel: '查看群聊发送者昵称字段说明',
40
+ conversationTitleHelpLabel: '查看群聊会话标题字段说明',
28
41
  guidanceLabel: '增强提示词',
29
42
  guidanceHelpLabel: '查看群聊增强提示词使用说明',
30
43
  guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -35,6 +48,7 @@ const SCOPE_COPY = Object.freeze({
35
48
  enable: '启用',
36
49
  fieldsHelpLabel: '查看私聊来源字段说明',
37
50
  senderNameHelpLabel: '查看私聊发送者昵称字段说明',
51
+ conversationTitleHelpLabel: '查看私聊会话标题字段说明',
38
52
  guidanceLabel: '增强提示词',
39
53
  guidanceHelpLabel: '查看私聊增强提示词使用说明',
40
54
  guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -73,7 +87,6 @@ function ContextEnhancementScopeEditor({
73
87
  const copy = SCOPE_COPY[kind];
74
88
  const unavailableId = `${idPrefix}-${kind}-unavailable`;
75
89
  const fieldsHelpId = `${idPrefix}-${kind}-fields-help`;
76
- const senderNameHelpId = `${idPrefix}-${kind}-sender-name-help`;
77
90
  const guidanceId = `${idPrefix}-${kind}-guidance`;
78
91
  const guidanceHelpId = `${idPrefix}-${kind}-guidance-help`;
79
92
  const disabled = busy || !supported;
@@ -118,16 +131,17 @@ function ContextEnhancementScopeEditor({
118
131
  }),
119
132
  h('span', { className: 'dim-contextFieldText' },
120
133
  h('label', { className: 'dim-contextFieldName', htmlFor: fieldId }, FIELD_LABELS[field]),
121
- field === 'senderName' ? h('span', { className: 'dim-contextHelp dim-contextFieldHelp' },
134
+ FIELD_HELP[field] ? h('span', { className: 'dim-contextHelp dim-contextFieldHelp' },
122
135
  h('button', {
123
136
  type: 'button', className: 'dim-contextHelpButton dim-contextFieldHelpButton', disabled,
124
- 'aria-label': copy.senderNameHelpLabel, 'aria-describedby': senderNameHelpId,
137
+ 'aria-label': copy[FIELD_HELP[field].labelKey],
138
+ 'aria-describedby': `${idPrefix}-${kind}-${field}-help`,
125
139
  }, h('span', { 'aria-hidden': 'true' }, '?')),
126
140
  h('span', {
127
- id: senderNameHelpId,
141
+ id: `${idPrefix}-${kind}-${field}-help`,
128
142
  className: 'dim-contextTooltip dim-contextFieldTooltip',
129
143
  role: 'tooltip',
130
- }, '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。')) : null,
144
+ }, FIELD_HELP[field].text)) : null,
131
145
  h('label', { className: 'dim-contextFieldKey', htmlFor: fieldId }, field)));
132
146
  }))),
133
147
  h('div', { className: 'dim-contextGuidance dim-contextScopeBlock' },
@@ -288,6 +288,10 @@ const EN = Object.freeze({
288
288
  '查看群聊发送者昵称字段说明': 'View group sender name field details',
289
289
  '查看私聊发送者昵称字段说明': 'View direct sender name field details',
290
290
  '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。': 'This field is not available on every channel. If the current message has no sender name, <dsh_im_source> omits senderName even when the field is selected.',
291
+ '会话标题': 'Conversation title',
292
+ '查看群聊会话标题字段说明': 'View group conversation title field details',
293
+ '查看私聊会话标题字段说明': 'View direct conversation title field details',
294
+ '该字段不是每个渠道都能提供。钉钉群聊会带上群名。当前消息没有会话标题时,即使已选择该字段,<dsh_im_source> 中也会省略 conversationTitle。': 'This field is not available on every channel. DingTalk group chats include the group name. If the current message has no conversation title, <dsh_im_source> omits conversationTitle even when the field is selected.',
291
295
  '机器人标识': 'Bot ID',
292
296
  '增强提示词': 'Guidance',
293
297
  '查看增强提示词使用说明': 'View guidance instructions',
@@ -315,7 +319,7 @@ const EN = Object.freeze({
315
319
  '上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
316
320
  '请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
317
321
  '群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
318
- '来源字段只能选择已定义的五个字段。': 'Source fields must be chosen from the five defined fields.',
322
+ '来源字段只能选择已定义的六个字段。': 'Source fields must be chosen from the six defined fields.',
319
323
  '保存中…': 'Saving…',
320
324
  '未设置': 'Not set',
321
325
  '工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
@@ -221,6 +221,9 @@ class ModernHarnessApi {
221
221
  { request: { requestId: request.rpcId, ...request.payload } },
222
222
  signal,
223
223
  )),
224
+ rename: (request, signal) => rpcResult(request, () => this.#invoke(
225
+ 'session', 'rename', { request: request.payload }, signal,
226
+ )),
224
227
  cancel: (request, signal) => rpcResult(request, () => this.#invoke(
225
228
  'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
226
229
  )),
@@ -39,7 +39,6 @@ import {
39
39
  hasInboundImages,
40
40
  imagePromptDiagnostic,
41
41
  imagePromptUserMessage,
42
- promptContentForMessage,
43
42
  } from '../shared/image-prompt.mjs';
44
43
  import {
45
44
  hasInboundFiles,
@@ -48,10 +47,16 @@ import {
48
47
  } from '../shared/inbound-file.mjs';
49
48
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
50
49
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
50
+ import {
51
+ hasReplyReference,
52
+ promptContentForInboundMessage,
53
+ } from '../shared/semantic/reply-reference.mjs';
51
54
  import {
52
55
  createDeliveryReceipt,
53
56
  providerMessageIdsFor,
54
57
  } from '../shared/semantic/delivery.mjs';
58
+ import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
59
+ import { DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS } from './state-store.mjs';
55
60
  import {
56
61
  channelDeliveryFailure,
57
62
  clearLastMessageFailure,
@@ -74,7 +79,7 @@ const HELP_TEXT_LINES = [
74
79
  '/new 开启一个全新会话',
75
80
  '/compact 压缩当前会话的较早上下文',
76
81
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
77
- '/workspace 工作区绝对路径 切换工作区',
82
+ '/workspace 工作区序号或绝对路径 切换工作区',
78
83
  '/workspacelist 列出工作区绝对路径',
79
84
  '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
80
85
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
@@ -185,11 +190,95 @@ function downloadCodeFor(value) {
185
190
  return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
186
191
  }
187
192
 
193
+ function dingtalkTimestampMs(value) {
194
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
195
+ if (!Number.isFinite(number) || number < 0) return null;
196
+ return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
197
+ }
198
+
199
+ function usefulReplyText(value) {
200
+ const text = nonEmptyString(value);
201
+ return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
202
+ }
203
+
204
+ function dingtalkReplyReference(message, options) {
205
+ const replyEnvelope = message?.text;
206
+ if (replyEnvelope?.isReplyMsg !== true) return null;
207
+ const replied = replyEnvelope?.repliedMsg;
208
+ if (!replied || typeof replied !== 'object') {
209
+ return { unavailableReason: 'not-delivered' };
210
+ }
211
+
212
+ const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
213
+ const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
214
+ const pseudoMessage = {
215
+ msgtype,
216
+ text: {
217
+ content: nonEmptyString(repliedContent.text)
218
+ ?? (typeof replied.content === 'string' ? replied.content : ''),
219
+ },
220
+ content: repliedContent,
221
+ };
222
+ const normalized = dingtalkInboundMessage(pseudoMessage, options);
223
+ let attachments = [];
224
+ if (msgtype === 'picture') {
225
+ attachments = [{ kind: 'image' }];
226
+ } else if (msgtype === 'file') {
227
+ const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
228
+ attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
229
+ } else if (msgtype === 'richtext') {
230
+ attachments = richTextEntries(repliedContent)
231
+ .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
232
+ .map(() => ({ kind: 'image' }));
233
+ } else if (msgtype === 'voice' || msgtype === 'audio') {
234
+ attachments = [{ kind: 'audio' }];
235
+ } else if (msgtype === 'video') {
236
+ attachments = [{ kind: 'video' }];
237
+ }
238
+
239
+ const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
240
+ const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
241
+ const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
242
+ const content = usefulReplyText(normalized.content)
243
+ ?? usefulReplyText(repliedContent.text)
244
+ ?? usefulReplyText(repliedContent.summary)
245
+ ?? usefulReplyText(repliedContent.title);
246
+ const processQueryKey = nonEmptyString(
247
+ message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
248
+ );
249
+ const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
250
+ const load = !content && attachments.length === 0
251
+ && typeof options?.loadReplyContent === 'function'
252
+ ? ({ signal } = {}) => options.loadReplyContent({
253
+ ...(messageId ? { messageId } : {}),
254
+ ...(processQueryKey ? { processQueryKey } : {}),
255
+ ...(createdAt === null ? {} : { createdAt }),
256
+ }, { signal })
257
+ : null;
258
+ const supported = [
259
+ 'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
260
+ 'interactivecard', 'chatrecord',
261
+ ]
262
+ .includes(msgtype);
263
+ return {
264
+ ...(messageId ? { messageId } : {}),
265
+ ...(authorId ? { authorId } : {}),
266
+ ...(authorName ? { authorName } : {}),
267
+ ...(content ? { content } : {}),
268
+ ...(attachments.length > 0 ? { attachments } : {}),
269
+ ...(load ? { load } : {}),
270
+ ...(!content && attachments.length === 0 && !load
271
+ ? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
272
+ : {}),
273
+ };
274
+ }
275
+
188
276
  /** Normalize DingTalk picture and richText callbacks into lazy image references. */
189
277
  export function dingtalkInboundMessage(message, {
190
278
  api,
191
279
  clientId,
192
280
  clientSecret,
281
+ loadReplyContent,
193
282
  } = {}) {
194
283
  const msgtype = String(message?.msgtype ?? '').toLowerCase();
195
284
  const content = parsedMessageContent(message);
@@ -209,6 +298,12 @@ export function dingtalkInboundMessage(message, {
209
298
  }
210
299
  }
211
300
  const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
301
+ const replyTo = dingtalkReplyReference(message, {
302
+ api,
303
+ clientId,
304
+ clientSecret,
305
+ loadReplyContent,
306
+ });
212
307
  return {
213
308
  content: text,
214
309
  images: imageCodes.map((downloadCode, index) => ({
@@ -242,6 +337,7 @@ export function dingtalkInboundMessage(message, {
242
337
  });
243
338
  },
244
339
  }] : [],
340
+ ...(replyTo ? { replyTo } : {}),
245
341
  };
246
342
  }
247
343
 
@@ -465,11 +561,7 @@ export class DingtalkHarnessBridge {
465
561
  // An unsafe reply route must never be able to submit an approval.
466
562
  }
467
563
  const pending = this.#pendingInteractions.get(key);
468
- const promptMessage = dingtalkInboundMessage(message, {
469
- api: this.#api,
470
- clientId: this.#clientId,
471
- clientSecret: this.#clientSecret,
472
- });
564
+ const promptMessage = this.#inboundMessage(message, key);
473
565
  const commandText = nonEmptyString(promptMessage.content) ?? '';
474
566
  const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
475
567
  const direct = String(message.conversationType) !== '2';
@@ -533,7 +625,8 @@ export class DingtalkHarnessBridge {
533
625
  plainText: Boolean(commandText)
534
626
  && String(message?.msgtype).toLowerCase() === 'text'
535
627
  && !hasInboundFiles(promptMessage)
536
- && !hasInboundImages(promptMessage),
628
+ && !hasInboundImages(promptMessage)
629
+ && !hasReplyReference(promptMessage),
537
630
  });
538
631
  if (result.handled) {
539
632
  if (result.kind === 'submit') {
@@ -778,11 +871,7 @@ export class DingtalkHarnessBridge {
778
871
  }
779
872
  const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
780
873
  const preparedMessage = hasSafeReplyRoute && addressed
781
- ? prefetchInboundFiles(dingtalkInboundMessage(message, {
782
- api: this.#api,
783
- clientId: this.#clientId,
784
- clientSecret: this.#clientSecret,
785
- }), { signal: this.#signal })
874
+ ? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
786
875
  : undefined;
787
876
  const previous = this.#queues.get(key) ?? Promise.resolve();
788
877
  const current = previous
@@ -801,6 +890,50 @@ export class DingtalkHarnessBridge {
801
890
  return current;
802
891
  }
803
892
 
893
+ #inboundMessage(message, key) {
894
+ return dingtalkInboundMessage(message, {
895
+ api: this.#api,
896
+ clientId: this.#clientId,
897
+ clientSecret: this.#clientSecret,
898
+ loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
899
+ });
900
+ }
901
+
902
+ async #loadReplyContent(key, reference, { signal } = {}) {
903
+ const indexed = this.#state.recentOutboundTextFor?.({
904
+ conversationKey: key,
905
+ ...reference,
906
+ });
907
+ if (indexed) return { content: indexed };
908
+ const quotedAt = dingtalkTimestampMs(reference?.createdAt);
909
+ if (quotedAt === null) return { unavailableReason: 'not-delivered' };
910
+ const sessionId = this.#state.sessionFor(key);
911
+ const session = typeof sessionId === 'string' && sessionId
912
+ ? this.#harness.workspaceSession?.(sessionId)
913
+ : null;
914
+ const text = await recoverAssistantTextByTimestamp({
915
+ session,
916
+ quotedAt,
917
+ signal,
918
+ toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
919
+ });
920
+ if (!text) return { unavailableReason: 'not-delivered' };
921
+ try {
922
+ await this.#state.rememberOutboundMessage?.({
923
+ conversationKey: key,
924
+ text,
925
+ sentAt: quotedAt,
926
+ completedAt: quotedAt,
927
+ providerMessageIds: [reference?.processQueryKey, reference?.messageId]
928
+ .map(nonEmptyString)
929
+ .filter(Boolean),
930
+ });
931
+ } catch (error) {
932
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
933
+ }
934
+ return { content: text };
935
+ }
936
+
804
937
  async waitForIdle() {
805
938
  await Promise.allSettled([
806
939
  ...this.#queues.values(),
@@ -936,20 +1069,18 @@ export class DingtalkHarnessBridge {
936
1069
  return;
937
1070
  }
938
1071
 
939
- const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
940
- api: this.#api,
941
- clientId: this.#clientId,
942
- clientSecret: this.#clientSecret,
943
- });
1072
+ const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
944
1073
  const text = promptMessage.content;
945
1074
  const hasImages = hasInboundImages(promptMessage);
946
1075
  const hasFiles = hasInboundFiles(promptMessage);
1076
+ const hasReply = hasReplyReference(promptMessage);
947
1077
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
948
1078
  let cardStream = null;
949
1079
  let cardStarted = false;
1080
+ let cardStartedAt = null;
950
1081
  let batchSettled = batchSubmission === null;
951
1082
  try {
952
- if (!text && !hasImages && !hasFiles) {
1083
+ if (!text && !hasImages && !hasFiles && !hasReply) {
953
1084
  await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
954
1085
  return;
955
1086
  }
@@ -992,16 +1123,20 @@ export class DingtalkHarnessBridge {
992
1123
  return;
993
1124
  }
994
1125
 
995
- let content = hasImages
996
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
1126
+ let content = hasImages || hasReply
1127
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
997
1128
  : undefined;
998
1129
  const snapshot = this.#acceptedMessageIds.get(messageId);
1130
+ let contextEnhanced = false;
999
1131
  if (snapshot) {
1000
- content = enhanceContextContent(content ?? text, snapshot, () => ({
1132
+ const originalContent = content ?? text;
1133
+ content = enhanceContextContent(originalContent, snapshot, () => ({
1001
1134
  channel: 'dingtalk',
1002
1135
  senderId: sender,
1003
1136
  senderName: message.senderNick,
1137
+ conversationTitle: message.conversationTitle,
1004
1138
  }));
1139
+ contextEnhanced = content !== originalContent;
1005
1140
  }
1006
1141
  if (typeof this.#api.createAiCard === 'function'
1007
1142
  && typeof this.#api.updateAiCard === 'function'
@@ -1014,13 +1149,17 @@ export class DingtalkHarnessBridge {
1014
1149
  signal: this.#signal,
1015
1150
  logger: this.#logger,
1016
1151
  });
1152
+ const startedAt = Date.now();
1017
1153
  cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
1154
+ if (cardStarted) cardStartedAt = startedAt;
1018
1155
  }
1019
1156
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1020
1157
  harness: this.#harness,
1021
1158
  state: this.#state,
1022
1159
  key,
1023
- ...(content !== undefined ? { content } : { text }),
1160
+ text,
1161
+ content,
1162
+ contextEnhanced,
1024
1163
  createOptions: { signal: this.#signal },
1025
1164
  existsOptions: { signal: this.#signal },
1026
1165
  askOptions: {
@@ -1050,12 +1189,14 @@ export class DingtalkHarnessBridge {
1050
1189
  let textDeliveryError = null;
1051
1190
  let textReceipt = null;
1052
1191
  let streamed = false;
1192
+ const deliveryStartedAt = cardStartedAt ?? Date.now();
1053
1193
  try {
1054
1194
  streamed = cardStarted && await cardStream.finish(answerText);
1055
1195
  if (streamed) {
1056
1196
  textReceipt = createDeliveryReceipt({
1057
1197
  deliveryId: messageId,
1058
1198
  presentation: 'dingtalk-card',
1199
+ providerMessageIds: cardStream.providerMessageIds,
1059
1200
  });
1060
1201
  } else {
1061
1202
  textReceipt = createDeliveryReceipt({
@@ -1064,6 +1205,17 @@ export class DingtalkHarnessBridge {
1064
1205
  providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
1065
1206
  });
1066
1207
  }
1208
+ try {
1209
+ await this.#state.rememberOutboundMessage?.({
1210
+ conversationKey: key,
1211
+ text: answerText,
1212
+ sentAt: deliveryStartedAt,
1213
+ completedAt: Date.now(),
1214
+ providerMessageIds: providerMessageIdsFor(textReceipt),
1215
+ });
1216
+ } catch (error) {
1217
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
1218
+ }
1067
1219
  } catch (error) {
1068
1220
  textDeliveryError = channelDeliveryFailure(error);
1069
1221
  }
@@ -32,7 +32,7 @@ function requiredCredential(value, name) {
32
32
  * @param {number} [options.updateIntervalMs=500] Minimum delay between updates.
33
33
  * @param {()=>number} [options.clock] Monotonic millisecond clock.
34
34
  * @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation.
35
- * @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>}}
35
+ * @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>, readonly providerMessageIds: string[]}}
36
36
  * Card stream controller.
37
37
  */
38
38
  export function createDingTalkCardStream({
@@ -231,5 +231,12 @@ export function createDingTalkCardStream({
231
231
  return finishPromise;
232
232
  };
233
233
 
234
- return Object.freeze({ start, push, finish });
234
+ return Object.freeze({
235
+ start,
236
+ push,
237
+ finish,
238
+ get providerMessageIds() {
239
+ return cardRequest?.cardInstanceId ? [cardRequest.cardInstanceId] : [];
240
+ },
241
+ });
235
242
  }
@@ -9,8 +9,14 @@ const EMPTY_STATE = Object.freeze({
9
9
  sessions: {},
10
10
  seenMessageIds: [],
11
11
  pendingSenders: {},
12
+ recentOutboundMessages: [],
12
13
  });
13
14
 
15
+ export const DINGTALK_RECENT_OUTBOUND_LIMIT = 200;
16
+ export const DINGTALK_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
17
+ export const DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
18
+ export const DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
19
+
14
20
  function nonEmptyString(value) {
15
21
  return typeof value === 'string' && value.trim() ? value.trim() : null;
16
22
  }
@@ -19,6 +25,49 @@ function displayName(value) {
19
25
  return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
20
26
  }
21
27
 
28
+ function timestampMs(value) {
29
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
30
+ if (!Number.isFinite(number) || number < 0) return null;
31
+ return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
32
+ }
33
+
34
+ function providerMessageIds(value) {
35
+ if (!Array.isArray(value)) return [];
36
+ return [...new Set(value
37
+ .map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
38
+ .filter(Boolean))];
39
+ }
40
+
41
+ function truncateText(value) {
42
+ const text = nonEmptyString(value);
43
+ return text ? [...text].slice(0, DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
44
+ }
45
+
46
+ function normalizeRecentOutboundMessage(value) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
48
+ const conversationKey = nonEmptyString(value.conversationKey);
49
+ const text = truncateText(value.text);
50
+ const sentAt = timestampMs(value.sentAt);
51
+ const completedAt = timestampMs(value.completedAt) ?? sentAt;
52
+ if (!conversationKey || !text || sentAt === null || completedAt === null) return null;
53
+ return {
54
+ conversationKey,
55
+ text,
56
+ sentAt,
57
+ completedAt: Math.max(sentAt, completedAt),
58
+ providerMessageIds: providerMessageIds(value.providerMessageIds),
59
+ };
60
+ }
61
+
62
+ function recentOutboundMessages(value, now = Date.now()) {
63
+ if (!Array.isArray(value)) return [];
64
+ const cutoff = now - DINGTALK_RECENT_OUTBOUND_TTL_MS;
65
+ return value
66
+ .map(normalizeRecentOutboundMessage)
67
+ .filter((entry) => entry && entry.completedAt >= cutoff)
68
+ .slice(-DINGTALK_RECENT_OUTBOUND_LIMIT);
69
+ }
70
+
22
71
  function normalizePendingSender(value, fallbackRequestId) {
23
72
  if (!value || typeof value !== 'object') return null;
24
73
  const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
@@ -69,6 +118,7 @@ function normalizeState(value) {
69
118
  ? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
70
119
  : [],
71
120
  pendingSenders,
121
+ recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
72
122
  };
73
123
  }
74
124
 
@@ -140,6 +190,54 @@ export class DingtalkStateStore {
140
190
  await this.#persist();
141
191
  }
142
192
 
193
+ async rememberOutboundMessage({
194
+ conversationKey,
195
+ text,
196
+ sentAt = Date.now(),
197
+ completedAt = Date.now(),
198
+ providerMessageIds: messageIds = [],
199
+ } = {}) {
200
+ const entry = normalizeRecentOutboundMessage({
201
+ conversationKey,
202
+ text,
203
+ sentAt,
204
+ completedAt,
205
+ providerMessageIds: messageIds,
206
+ });
207
+ if (!entry) throw new TypeError('Invalid DingTalk outbound message');
208
+ this.#state.recentOutboundMessages = recentOutboundMessages([
209
+ ...this.#state.recentOutboundMessages,
210
+ entry,
211
+ ]);
212
+ await this.#persist();
213
+ }
214
+
215
+ recentOutboundTextFor({
216
+ conversationKey,
217
+ processQueryKey,
218
+ messageId,
219
+ createdAt,
220
+ now = Date.now(),
221
+ } = {}) {
222
+ const key = nonEmptyString(conversationKey);
223
+ if (!key) return null;
224
+ const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
225
+ .filter((entry) => entry.conversationKey === key);
226
+ const quotedIds = providerMessageIds([processQueryKey, messageId]);
227
+ for (const quotedId of quotedIds) {
228
+ const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedId));
229
+ if (exact.length === 1) return exact[0].text;
230
+ if (exact.length > 1) return null;
231
+ }
232
+ const quotedAt = timestampMs(createdAt);
233
+ if (quotedAt === null) return null;
234
+ const candidates = active.filter((entry) => (
235
+ quotedAt >= entry.sentAt - DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
236
+ && quotedAt <= entry.completedAt + DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
237
+ ));
238
+ return candidates.length === 1 ? candidates[0].text : null;
239
+ }
240
+
143
241
  pendingSenders() {
144
242
  return Object.values(this.#state.pendingSenders)
145
243
  .sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
@@ -134,6 +134,13 @@ export class DiscordApi {
134
134
  });
135
135
  }
136
136
 
137
+ getMessage({ channelId, messageId, signal } = {}) {
138
+ return this.#request(
139
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
140
+ { method: 'GET', signal },
141
+ );
142
+ }
143
+
137
144
  startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
138
145
  const threadName = cleanString(name);
139
146
  if (!threadName || [...threadName].length > 100) {