@xmanrui/dsh-im 3.1.0 → 3.2.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 (64) hide show
  1. package/README.en.md +15 -0
  2. package/README.md +15 -0
  3. package/lib/client.js +1653 -333
  4. package/lib/index.js +235 -223
  5. package/package.json +5 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +3 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +18 -5
  8. package/plugin-src/client/channels/feishu/api.js +3 -0
  9. package/plugin-src/client/channels/feishu/index.js +18 -5
  10. package/plugin-src/client/channels/qq/api.js +3 -0
  11. package/plugin-src/client/channels/qq/index.js +13 -0
  12. package/plugin-src/client/channels/shared/token-api.js +3 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +13 -1
  14. package/plugin-src/client/channels/wecom/api.js +3 -0
  15. package/plugin-src/client/channels/wecom/index.js +13 -0
  16. package/plugin-src/client/channels/weixin/api.js +3 -0
  17. package/plugin-src/client/channels/weixin/index.js +19 -5
  18. package/plugin-src/client/channels/whatsapp/api.js +3 -0
  19. package/plugin-src/client/channels/whatsapp/index.js +13 -0
  20. package/plugin-src/client/context-enhancement.js +275 -0
  21. package/plugin-src/client/i18n.js +54 -3
  22. package/plugin-src/client/styles.js +78 -0
  23. package/plugin-src/client/update-panel.js +108 -7
  24. package/plugin-src/host/channels/dingtalk/production.mjs +1 -0
  25. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  27. package/plugin-src/host/channels/feishu/rpc.mjs +13 -0
  28. package/plugin-src/host/channels/qq/production.mjs +1 -0
  29. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/shared/context-enhancement-rpc.mjs +17 -0
  31. package/plugin-src/host/channels/shared/production.mjs +1 -0
  32. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  33. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  34. package/plugin-src/host/channels/slack/production.mjs +1 -0
  35. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  36. package/plugin-src/host/channels/wecom/production.mjs +1 -0
  37. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  38. package/plugin-src/host/channels/weixin/production.mjs +1 -0
  39. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  40. package/plugin-src/host/channels/whatsapp/production.mjs +1 -0
  41. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  42. package/plugin-src/host/update-runtime.mjs +3 -2
  43. package/plugin-src/host/update-service.mjs +2 -0
  44. package/scripts/verify-package.mjs +15 -2
  45. package/src/channels/dingtalk/dingtalk-api.mjs +39 -16
  46. package/src/channels/dingtalk/dingtalk-bridge.mjs +52 -23
  47. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -1
  48. package/src/channels/discord/discord-runtime.mjs +18 -4
  49. package/src/channels/feishu/bridge.mjs +20 -4
  50. package/src/channels/feishu/feishu-channel.mjs +100 -44
  51. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  52. package/src/channels/qq/qq-bridge.mjs +20 -4
  53. package/src/channels/qq/qq-runtime.mjs +4 -0
  54. package/src/channels/shared/bot-workspace-store.mjs +103 -6
  55. package/src/channels/shared/context-enhancement.mjs +135 -0
  56. package/src/channels/shared/i18n-en/feishu.mjs +2 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +19 -4
  58. package/src/channels/slack/slack-runtime.mjs +4 -0
  59. package/src/channels/telegram/telegram-runtime.mjs +24 -2
  60. package/src/channels/wecom/wecom-bridge.mjs +18 -3
  61. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  62. package/src/channels/weixin/weixin-bridge.mjs +19 -4
  63. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  64. package/src/channels/whatsapp/whatsapp-runtime.mjs +5 -0
@@ -28,6 +28,7 @@ import {
28
28
  } from '../shared/preset-command.mjs';
29
29
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
30
30
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
31
+ import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
31
32
  import {
32
33
  BatchInputManager,
33
34
  batchInputBusyMessage,
@@ -251,7 +252,11 @@ function conversationKey(message, sender) {
251
252
 
252
253
  function cardTarget(message, sender) {
253
254
  if (String(message?.conversationType) === '2') {
254
- return { type: 'group', openConversationId: nonEmptyString(message?.conversationId) };
255
+ return {
256
+ type: 'group',
257
+ openConversationId: nonEmptyString(message?.conversationId),
258
+ atUserIds: { [sender]: nonEmptyString(message?.senderNick) ?? sender },
259
+ };
255
260
  }
256
261
  return { type: 'user', userId: sender };
257
262
  }
@@ -368,6 +373,7 @@ export class DingtalkHarnessBridge {
368
373
  #clientSecret;
369
374
  #harness;
370
375
  #state;
376
+ #contextEnhancement;
371
377
  #status;
372
378
  #logger;
373
379
  #replyTimeoutMs;
@@ -379,7 +385,8 @@ export class DingtalkHarnessBridge {
379
385
  #interactionKeys = new Map();
380
386
  #interactionTasks = new Set();
381
387
  #commandTasks = new Set();
382
- #acceptedMessageIds = new Set();
388
+ // Keep the accepted configuration through the existing queue/reply lifecycle.
389
+ #acceptedMessageIds = new Map();
383
390
  #approvals;
384
391
  #batchInputs = new BatchInputManager();
385
392
 
@@ -389,6 +396,7 @@ export class DingtalkHarnessBridge {
389
396
  clientSecret,
390
397
  harness,
391
398
  state,
399
+ contextEnhancement,
392
400
  status = createDingtalkBridgeStatus(),
393
401
  logger = console,
394
402
  replyTimeoutMs = 600_000,
@@ -406,6 +414,7 @@ export class DingtalkHarnessBridge {
406
414
  this.#clientSecret = clientSecret.trim();
407
415
  this.#harness = harness;
408
416
  this.#state = state;
417
+ this.#contextEnhancement = contextEnhancement;
409
418
  this.#status = status;
410
419
  this.#logger = logger;
411
420
  this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
@@ -424,13 +433,17 @@ export class DingtalkHarnessBridge {
424
433
  return structuredClone(this.#status);
425
434
  }
426
435
 
427
- accept(message) {
436
+ accept(message, { contextSnapshot } = {}) {
428
437
  if (this.#signal?.aborted) return Promise.resolve();
429
438
  const messageId = nonEmptyString(message?.msgId);
430
439
  const sender = senderStaffId(message);
431
440
  if (!messageId || !sender || this.#state.hasSeen(messageId)
432
441
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
433
- this.#acceptedMessageIds.add(messageId);
442
+ this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
443
+ this.#contextEnhancement,
444
+ message.conversationType === '1' || message.conversationType === 1 ? 'direct'
445
+ : message.conversationType === '2' || message.conversationType === 2 ? 'group' : null,
446
+ ) : contextSnapshot);
434
447
 
435
448
  let key;
436
449
  try {
@@ -551,7 +564,7 @@ export class DingtalkHarnessBridge {
551
564
  `[dsh-dingtalk] failed to process a command [${failure.referenceId}]`,
552
565
  safeErrorDiagnostic(error),
553
566
  );
554
- return this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
567
+ return this.#send(sessionWebhook, messageFailureText(failure), this.#atUsersFor(message)).catch(() => undefined);
555
568
  }).finally(() => {
556
569
  this.#acceptedMessageIds.delete(messageId);
557
570
  this.#commandTasks.delete(task);
@@ -573,7 +586,7 @@ export class DingtalkHarnessBridge {
573
586
  : null,
574
587
  isQuestionPending: () => this.#pendingInteractions.has(key),
575
588
  send: sessionWebhook
576
- ? (reply) => this.#send(sessionWebhook, reply)
589
+ ? (reply) => this.#send(sessionWebhook, reply, this.#atUsersFor(message))
577
590
  : async () => undefined,
578
591
  });
579
592
  if (approvalReply) {
@@ -807,7 +820,7 @@ export class DingtalkHarnessBridge {
807
820
  ]);
808
821
  }
809
822
  for (const reply of result?.messages ?? [result?.message]) {
810
- if (reply) await this.#send(sessionWebhook, reply);
823
+ if (reply) await this.#send(sessionWebhook, reply, this.#atUsersFor(message));
811
824
  }
812
825
  this.#status.lastError = null;
813
826
  }
@@ -885,23 +898,23 @@ export class DingtalkHarnessBridge {
885
898
  let batchSettled = batchSubmission === null;
886
899
  try {
887
900
  if (!text && !hasImages && !hasFiles) {
888
- await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
901
+ await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
889
902
  return;
890
903
  }
891
904
 
892
905
  const command = text.toLowerCase();
893
906
  if (isPlainText && !hasImages && !hasFiles && command === '/help') {
894
- await this.#send(sessionWebhook, helpText());
907
+ await this.#send(sessionWebhook, helpText(), this.#atUsersFor(message));
895
908
  return;
896
909
  }
897
910
  if (isPlainText && !hasImages && !hasFiles && command === '/status') {
898
911
  await this.#harness.ensureRunning({ signal: this.#signal });
899
- await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'));
912
+ await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'), this.#atUsersFor(message));
900
913
  return;
901
914
  }
902
915
  if (isPlainText && !hasImages && !hasFiles && command === '/new') {
903
916
  await this.#state.clearSession(key);
904
- await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'));
917
+ await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'), this.#atUsersFor(message));
905
918
  return;
906
919
  }
907
920
  const workspaceCommand = isPlainText && !hasImages && !hasFiles
@@ -909,7 +922,7 @@ export class DingtalkHarnessBridge {
909
922
  : null;
910
923
  if (workspaceCommand) {
911
924
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
912
- await this.#send(sessionWebhook, reply);
925
+ await this.#send(sessionWebhook, reply, this.#atUsersFor(message));
913
926
  }
914
927
  return;
915
928
  }
@@ -923,13 +936,21 @@ export class DingtalkHarnessBridge {
923
936
  )
924
937
  : null;
925
938
  if (compactCommand) {
926
- await this.#send(sessionWebhook, compactCommand.message);
939
+ await this.#send(sessionWebhook, compactCommand.message, this.#atUsersFor(message));
927
940
  return;
928
941
  }
929
942
 
930
- const content = hasImages
943
+ let content = hasImages
931
944
  ? await promptContentForMessage(promptMessage, { signal: this.#signal })
932
945
  : undefined;
946
+ const snapshot = this.#acceptedMessageIds.get(messageId);
947
+ if (snapshot) {
948
+ content = enhanceContextContent(content ?? text, snapshot, () => ({
949
+ channel: 'dingtalk',
950
+ senderId: sender,
951
+ senderName: message.senderNick,
952
+ }));
953
+ }
933
954
  if (typeof this.#api.createAiCard === 'function'
934
955
  && typeof this.#api.updateAiCard === 'function'
935
956
  && typeof this.#api.finishAiCard === 'function') {
@@ -947,7 +968,7 @@ export class DingtalkHarnessBridge {
947
968
  harness: this.#harness,
948
969
  state: this.#state,
949
970
  key,
950
- ...(hasImages ? { content } : { text }),
971
+ ...(content !== undefined ? { content } : { text }),
951
972
  createOptions: { signal: this.#signal },
952
973
  existsOptions: { signal: this.#signal },
953
974
  askOptions: {
@@ -988,7 +1009,7 @@ export class DingtalkHarnessBridge {
988
1009
  textReceipt = createDeliveryReceipt({
989
1010
  deliveryId: messageId,
990
1011
  presentation: 'dingtalk-text',
991
- providerMessageIds: await this.#send(sessionWebhook, answerText),
1012
+ providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
992
1013
  });
993
1014
  }
994
1015
  } catch (error) {
@@ -1049,7 +1070,7 @@ export class DingtalkHarnessBridge {
1049
1070
  ? `${errorText}\n\n${batchFailureMessage}`
1050
1071
  : errorText;
1051
1072
  const streamed = cardStarted && await cardStream.finish(visibleError);
1052
- if (!streamed) await this.#send(sessionWebhook, visibleError);
1073
+ if (!streamed) await this.#send(sessionWebhook, visibleError, this.#atUsersFor(message));
1053
1074
  } catch {
1054
1075
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
1055
1076
  }
@@ -1102,7 +1123,7 @@ export class DingtalkHarnessBridge {
1102
1123
  const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
1103
1124
  if (!text) {
1104
1125
  try {
1105
- await this.#send(sessionWebhook, t('请用文字回答当前问题。'));
1126
+ await this.#send(sessionWebhook, t('请用文字回答当前问题。'), this.#atUsersFor(message));
1106
1127
  } catch {
1107
1128
  this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
1108
1129
  }
@@ -1113,7 +1134,7 @@ export class DingtalkHarnessBridge {
1113
1134
  if (!pending || pending !== expected || pending.submitting) {
1114
1135
  if (claimed && (!pending || pending !== expected)) {
1115
1136
  try {
1116
- await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
1137
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT), this.#atUsersFor(message));
1117
1138
  } catch {
1118
1139
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
1119
1140
  }
@@ -1178,7 +1199,7 @@ export class DingtalkHarnessBridge {
1178
1199
  if (error?.code === 'interaction-not-pending') {
1179
1200
  this.#clearPendingInteraction(key, pending.interactionId);
1180
1201
  try {
1181
- await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
1202
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT), this.#atUsersFor(message));
1182
1203
  } catch {
1183
1204
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
1184
1205
  }
@@ -1191,7 +1212,7 @@ export class DingtalkHarnessBridge {
1191
1212
  this.#status.lastError = t('回答提交失败。');
1192
1213
  this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
1193
1214
  try {
1194
- await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'));
1215
+ await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'), this.#atUsersFor(message));
1195
1216
  } catch {
1196
1217
  this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
1197
1218
  }
@@ -1323,7 +1344,7 @@ export class DingtalkHarnessBridge {
1323
1344
  return;
1324
1345
  }
1325
1346
  try {
1326
- await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
1347
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT), this.#atUsersFor(message));
1327
1348
  } catch {
1328
1349
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
1329
1350
  }
@@ -1367,7 +1388,14 @@ export class DingtalkHarnessBridge {
1367
1388
  }
1368
1389
  }
1369
1390
 
1370
- async #send(sessionWebhook, text) {
1391
+ #atUsersFor(message) {
1392
+ const sender = senderStaffId(message);
1393
+ return String(message?.conversationType) === '2' && sender
1394
+ ? { atUserIds: [sender] }
1395
+ : undefined;
1396
+ }
1397
+
1398
+ async #send(sessionWebhook, text, at) {
1371
1399
  const providerMessageIds = [];
1372
1400
  for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
1373
1401
  this.#signal?.throwIfAborted();
@@ -1376,6 +1404,7 @@ export class DingtalkHarnessBridge {
1376
1404
  clientSecret: this.#clientSecret,
1377
1405
  sessionWebhook,
1378
1406
  text: chunk,
1407
+ at,
1379
1408
  signal: this.#signal,
1380
1409
  });
1381
1410
  providerMessageIds.push(...providerMessageIdsFor(result));
@@ -5,6 +5,7 @@ import {
5
5
  } from './dingtalk-bridge.mjs';
6
6
  import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
7
  import { t } from '../shared/i18n.mjs';
8
+ import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
8
9
 
9
10
  function nonEmptyString(value) {
10
11
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -128,6 +129,7 @@ export class DingtalkRuntime {
128
129
  #clientSecret;
129
130
  #harness;
130
131
  #state;
132
+ #contextEnhancement;
131
133
  #logger;
132
134
  #replyTimeoutMs;
133
135
  #maxMessageChars;
@@ -149,6 +151,7 @@ export class DingtalkRuntime {
149
151
  clientSecret,
150
152
  harness,
151
153
  state,
154
+ contextEnhancement,
152
155
  logger = console,
153
156
  replyTimeoutMs = 600_000,
154
157
  maxMessageChars = 4_000,
@@ -166,6 +169,7 @@ export class DingtalkRuntime {
166
169
  this.#clientSecret = clientSecret.trim();
167
170
  this.#harness = harness;
168
171
  this.#state = state;
172
+ this.#contextEnhancement = contextEnhancement;
169
173
  this.#logger = logger;
170
174
  this.#replyTimeoutMs = replyTimeoutMs;
171
175
  this.#maxMessageChars = maxMessageChars;
@@ -233,6 +237,7 @@ export class DingtalkRuntime {
233
237
  approvedSenders: this.#config.approvedSenders,
234
238
  harness: this.#harness,
235
239
  state: this.#state,
240
+ contextEnhancement: this.#contextEnhancement,
236
241
  status: this.#status,
237
242
  logger: this.#logger,
238
243
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -268,6 +273,15 @@ export class DingtalkRuntime {
268
273
  }
269
274
  }
270
275
 
276
+ // Retain the committed, read-only settings at receipt without moving
277
+ // JSON parsing out of the existing asynchronous callback path.
278
+ const contextEnhancement = this.#contextEnhancement;
279
+ let receivedSettings;
280
+ try {
281
+ receivedSettings = contextEnhancement?.getSettings?.();
282
+ } catch {
283
+ // Optional settings failures leave the original message path active.
284
+ }
271
285
  const task = Promise.resolve().then(async () => {
272
286
  if (this.#bridge !== bridge) return;
273
287
  let message;
@@ -282,7 +296,12 @@ export class DingtalkRuntime {
282
296
  }
283
297
  if (!message || typeof message !== 'object') return;
284
298
  this.#status.lastCallbackAt = Date.now();
285
- await bridge.accept(message);
299
+ const contextSnapshot = captureContextEnhancement({
300
+ getSettings: () => receivedSettings,
301
+ get botId() { return contextEnhancement?.botId; },
302
+ }, message.conversationType === '1' || message.conversationType === 1 ? 'direct'
303
+ : message.conversationType === '2' || message.conversationType === 2 ? 'group' : null);
304
+ await bridge.accept(message, { contextSnapshot });
286
305
  }).catch(() => {
287
306
  if (signal.aborted || this.#bridge !== bridge) return;
288
307
  this.#status.lastError = t('钉钉消息处理失败。');
@@ -2,6 +2,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
2
2
  import { fetchFileStream } from '../shared/file-download.mjs';
3
3
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
4
4
  import { t } from '../shared/i18n.mjs';
5
+ import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
5
6
  import { DiscordApi } from './discord-api.mjs';
6
7
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
7
8
 
@@ -213,6 +214,10 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
213
214
  return {
214
215
  messageId: String(message.id),
215
216
  senderId: String(message.author.id),
217
+ contextSource: () => ({
218
+ senderName: [message.member?.nick, message.author.global_name, message.author.username]
219
+ .find((value) => typeof value === 'string' && value.trim()),
220
+ }),
216
221
  senderIsBot: message.author.bot === true,
217
222
  kind: direct ? 'direct' : 'group',
218
223
  conversationId: String(message.channel_id),
@@ -427,6 +432,7 @@ export class DiscordRuntime {
427
432
  #token;
428
433
  #harness;
429
434
  #state;
435
+ #contextEnhancement;
430
436
  #logger;
431
437
  #replyTimeoutMs;
432
438
  #connectTimeoutMs;
@@ -457,6 +463,7 @@ export class DiscordRuntime {
457
463
  token,
458
464
  harness,
459
465
  state,
466
+ contextEnhancement,
460
467
  logger = console,
461
468
  replyTimeoutMs = 600_000,
462
469
  connectTimeoutMs = 20_000,
@@ -472,6 +479,7 @@ export class DiscordRuntime {
472
479
  this.#token = token;
473
480
  this.#harness = harness;
474
481
  this.#state = state;
482
+ this.#contextEnhancement = contextEnhancement;
475
483
  this.#logger = logger;
476
484
  this.#replyTimeoutMs = replyTimeoutMs;
477
485
  this.#connectTimeoutMs = connectTimeoutMs;
@@ -534,6 +542,7 @@ export class DiscordRuntime {
534
542
  bot: client,
535
543
  harness: this.#harness,
536
544
  state: this.#state,
545
+ contextEnhancement: this.#contextEnhancement,
537
546
  status: this.#status,
538
547
  logger: this.#logger,
539
548
  replyTimeoutMs: this.#replyTimeoutMs,
@@ -707,20 +716,25 @@ export class DiscordRuntime {
707
716
  if (!messageId || this.#state.hasSeen(messageId)) return;
708
717
  let route = this.#routing.get(messageId);
709
718
  if (!route) {
710
- route = resolveDiscordMessageRoute(message, this.#config.platformId, {
719
+ const contextSnapshot = captureContextEnhancement(
720
+ this.#contextEnhancement,
721
+ message.guild_id ? 'group' : 'direct',
722
+ );
723
+ const pendingRoute = resolveDiscordMessageRoute(message, this.#config.platformId, {
711
724
  api: this.#api,
712
725
  channel: this.#channels.get(String(message.channel_id)),
713
726
  signal: this.#abortController?.signal,
714
727
  onChannel: (resolved) => this.#rememberChannel(resolved),
715
728
  });
729
+ route = { pendingRoute, contextSnapshot };
716
730
  this.#routing.set(messageId, route);
717
- void route.finally(() => {
731
+ void pendingRoute.finally(() => {
718
732
  if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
719
733
  }).catch(() => undefined);
720
734
  }
721
735
  try {
722
- const normalized = await route;
723
- if (normalized) await bridge.accept(normalized);
736
+ const normalized = await route.pendingRoute;
737
+ if (normalized) await bridge.accept(normalized, { contextSnapshot: route.contextSnapshot });
724
738
  } catch (error) {
725
739
  if (error?.code === 'discord-thread-create-uncertain') {
726
740
  await this.#state.markSeen(messageId);
@@ -46,9 +46,11 @@ import {
46
46
  } from '../shared/preset-command.mjs';
47
47
  import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
48
48
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
49
+ import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
49
50
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
50
51
  import {
51
52
  createDeliveryReceipt,
53
+ providerMessageIdsFor,
52
54
  } from '../shared/semantic/delivery.mjs';
53
55
  import {
54
56
  channelDeliveryFailure,
@@ -382,12 +384,14 @@ export class FeishuHarnessBridge {
382
384
  #channel;
383
385
  #harness;
384
386
  #state;
387
+ #contextEnhancement;
385
388
  #queues = new Map();
386
389
  #batchInputs = new BatchInputManager();
387
390
  #pendingInteractions = new Map();
388
391
  #interactionKeys = new Map();
389
392
  #resolvedQuestionReplies = new Map();
390
- #acceptedMessageIds = new Set();
393
+ // Keep the accepted configuration through the existing queue/reply lifecycle.
394
+ #acceptedMessageIds = new Map();
391
395
  #interactionTasks = new Set();
392
396
  #commandTasks = new Set();
393
397
  /** All accepted card work, including tasks waiting behind an earlier click. */
@@ -446,6 +450,7 @@ export class FeishuHarnessBridge {
446
450
  channel,
447
451
  harness,
448
452
  state,
453
+ contextEnhancement,
449
454
  status,
450
455
  allowedSenderOpenIds = new Set(),
451
456
  botId,
@@ -482,6 +487,7 @@ export class FeishuHarnessBridge {
482
487
  this.#channel = channel;
483
488
  this.#harness = harness;
484
489
  this.#state = state;
490
+ this.#contextEnhancement = contextEnhancement;
485
491
  this.#status = status;
486
492
  this.#allowedSenderOpenIds = allowedSenderOpenIds;
487
493
  this.#botId = nonEmptyString(botId);
@@ -550,7 +556,10 @@ export class FeishuHarnessBridge {
550
556
  if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
551
557
  }
552
558
 
553
- this.#acceptedMessageIds.add(messageId);
559
+ this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
560
+ this.#contextEnhancement,
561
+ event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
562
+ ));
554
563
  const processingReaction = this.#beginReaction(messageId);
555
564
  const commandMessage = extractInboundMessage(event, this.#client);
556
565
  const commandText = nonEmptyString(commandMessage.content) ?? '';
@@ -3142,9 +3151,16 @@ export class FeishuHarnessBridge {
3142
3151
  askCompleted = true;
3143
3152
  onAskComplete?.();
3144
3153
  };
3145
- const content = hasInboundImages(message)
3154
+ let content = hasInboundImages(message)
3146
3155
  ? await promptContentForMessage(message, { signal: this.#signal })
3147
3156
  : undefined;
3157
+ const snapshot = this.#acceptedMessageIds.get(messageId);
3158
+ if (snapshot) {
3159
+ content = enhanceContextContent(content ?? text, snapshot, () => ({
3160
+ channel: 'feishu',
3161
+ senderId: senderOpenId(event),
3162
+ }));
3163
+ }
3148
3164
  if (!this.#channel?.stream) {
3149
3165
  const { answer, artifacts = [] } = await askInWorkspaceSession({
3150
3166
  harness: this.#harness,
@@ -3315,7 +3331,7 @@ export class FeishuHarnessBridge {
3315
3331
  createDeliveryReceipt({
3316
3332
  deliveryId: messageId,
3317
3333
  presentation: 'feishu-cardkit',
3318
- providerMessageIds: stream?.messageId ? [stream.messageId] : [],
3334
+ providerMessageIds: providerMessageIdsFor(stream),
3319
3335
  }),
3320
3336
  );
3321
3337
  this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
@@ -126,7 +126,37 @@ function deliveryUuid(file, chatId, messageType) {
126
126
 
127
127
  function summaryOf(text) {
128
128
  const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
129
- return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
129
+ return summary.length <= 50 ? summary : `${streamTextPrefix(summary, 49)}…`;
130
+ }
131
+
132
+ function streamTextPrefix(text, maxChars) {
133
+ let end = Math.min(text.length, maxChars);
134
+ // Keep a UTF-16 surrogate pair together when the limit lands inside an emoji.
135
+ const before = text.charCodeAt(end - 1);
136
+ const after = text.charCodeAt(end);
137
+ if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
138
+ return text.slice(0, end);
139
+ }
140
+
141
+ function streamPreview(text) {
142
+ if (text.length <= MAX_STREAM_CHARS) return text;
143
+ const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`;
144
+ return streamTextPrefix(text, MAX_STREAM_CHARS - notice.length) + notice;
145
+ }
146
+
147
+ function splitStreamContent(text) {
148
+ const chunks = [];
149
+ let remaining = text;
150
+ while (remaining.length > MAX_STREAM_CHARS) {
151
+ const prefix = streamTextPrefix(remaining, MAX_STREAM_CHARS);
152
+ let end = prefix.lastIndexOf('\n') + 1;
153
+ if (end < MAX_STREAM_CHARS * 0.6) end = prefix.length;
154
+ chunks.push(remaining.slice(0, end));
155
+ // Unlike plain-text fallback splitting, keep all whitespace in the answer.
156
+ remaining = remaining.slice(end);
157
+ }
158
+ if (remaining) chunks.push(remaining);
159
+ return chunks;
130
160
  }
131
161
 
132
162
  function streamingCard(initialText) {
@@ -164,7 +194,7 @@ export class VerifiedFeishuChannel {
164
194
  fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
165
195
  }) {
166
196
  this.#client = client;
167
- this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
197
+ this.#initialText = String(initialText ?? t(DEFAULT_INITIAL_TEXT)) || '…';
168
198
  this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
169
199
  this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
170
200
  }
@@ -174,59 +204,40 @@ export class VerifiedFeishuChannel {
174
204
  throw new Error('Feishu stream requires a markdown producer');
175
205
  }
176
206
 
177
- let messageId = null;
178
- const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
179
- data: {
180
- type: 'card_json',
181
- data: JSON.stringify(streamingCard(this.#initialText)),
182
- },
183
- }));
184
- const cardId = cardResponse?.data?.card_id;
185
- if (!cardId) throw new Error('Feishu card.create returned no card_id');
186
-
207
+ const cards = [];
187
208
  try {
188
- messageId = await this.#sendCard(chatId, cardId, options.replyTo);
189
- let sequence = 0;
209
+ const firstCard = await this.#createStreamCard(chatId, options.replyTo);
210
+ cards.push(firstCard);
190
211
  let lastContent = this.#initialText;
191
212
  const controller = {
192
- messageId,
213
+ messageId: firstCard.messageId,
193
214
  setContent: async (content) => {
194
215
  const next = String(content ?? '') || '…';
195
- if (next === lastContent) return;
196
- if (next.length > MAX_STREAM_CHARS) {
197
- throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`);
198
- }
199
- const response = await this.#client.cardkit.v1.cardElement.content({
200
- path: { card_id: cardId, element_id: STREAM_ELEMENT_ID },
201
- data: {
202
- content: next,
203
- sequence: ++sequence,
204
- uuid: `content_${cardId}_${sequence}`,
205
- },
206
- });
207
- assertApiSuccess('Feishu cardElement.content', response);
216
+ await this.#updateStreamCard(firstCard, streamPreview(next));
217
+ // Updates are replaceable snapshots, including progress/tool text.
218
+ // Retain the full latest snapshot even when its preview is unchanged.
208
219
  lastContent = next;
209
220
  },
210
221
  };
211
222
 
212
223
  await input.markdown(controller);
213
- const finishResponse = await this.#client.cardkit.v1.card.settings({
214
- path: { card_id: cardId },
215
- data: {
216
- settings: JSON.stringify({
217
- config: {
218
- streaming_mode: false,
219
- summary: { content: summaryOf(lastContent) || t('回答完成') },
220
- },
221
- }),
222
- sequence: ++sequence,
223
- uuid: `settings_${cardId}_${sequence}`,
224
- },
225
- });
226
- assertApiSuccess('Feishu card.settings', finishResponse);
227
- return { messageId };
224
+ const chunks = splitStreamContent(lastContent);
225
+ for (const [index, chunk] of chunks.entries()) {
226
+ const card = index === 0
227
+ ? firstCard
228
+ : await this.#createStreamCard(chatId, options.replyTo);
229
+ if (index > 0) cards.push(card);
230
+ await this.#updateStreamCard(card, chunk);
231
+ await this.#finishStreamCard(card);
232
+ }
233
+ return {
234
+ messageId: firstCard.messageId,
235
+ providerMessageIds: cards.map((card) => card.messageId),
236
+ };
228
237
  } catch (error) {
229
- if (messageId) await this.#recall(messageId);
238
+ // Preserve the existing provider-error fallback contract: the bridge
239
+ // resends the completed answer, so remove any cards it would duplicate.
240
+ for (const card of cards) await this.#recall(card.messageId);
230
241
  throw error;
231
242
  }
232
243
  }
@@ -369,6 +380,51 @@ export class VerifiedFeishuChannel {
369
380
  });
370
381
  }
371
382
 
383
+ async #createStreamCard(chatId, replyTo) {
384
+ const content = streamPreview(this.#initialText);
385
+ const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
386
+ data: {
387
+ type: 'card_json',
388
+ data: JSON.stringify(streamingCard(content)),
389
+ },
390
+ }));
391
+ const cardId = response?.data?.card_id;
392
+ if (!cardId) throw new Error('Feishu card.create returned no card_id');
393
+ const messageId = await this.#sendCard(chatId, cardId, replyTo);
394
+ return { cardId, messageId, content, sequence: 0 };
395
+ }
396
+
397
+ async #updateStreamCard(card, content) {
398
+ if (content === card.content) return;
399
+ const response = await this.#client.cardkit.v1.cardElement.content({
400
+ path: { card_id: card.cardId, element_id: STREAM_ELEMENT_ID },
401
+ data: {
402
+ content,
403
+ sequence: ++card.sequence,
404
+ uuid: `content_${card.cardId}_${card.sequence}`,
405
+ },
406
+ });
407
+ assertApiSuccess('Feishu cardElement.content', response);
408
+ card.content = content;
409
+ }
410
+
411
+ async #finishStreamCard(card) {
412
+ const response = await this.#client.cardkit.v1.card.settings({
413
+ path: { card_id: card.cardId },
414
+ data: {
415
+ settings: JSON.stringify({
416
+ config: {
417
+ streaming_mode: false,
418
+ summary: { content: summaryOf(card.content) || t('回答完成') },
419
+ },
420
+ }),
421
+ sequence: ++card.sequence,
422
+ uuid: `settings_${card.cardId}_${card.sequence}`,
423
+ },
424
+ });
425
+ assertApiSuccess('Feishu card.settings', response);
426
+ }
427
+
372
428
  async #sendCard(chatId, cardId, replyTo) {
373
429
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
374
430
  const response = replyTo