@xmanrui/dsh-im 4.2.0 → 4.3.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.
@@ -686,7 +686,7 @@ export class FeishuHarnessBridge {
686
686
  ? pending.queue
687
687
  : null,
688
688
  isQuestionPending: () => this.#pendingInteractions.has(key),
689
- send: (text) => this.#send(event.message.chat_id, text),
689
+ send: (text) => this.#send(event.message.chat_id, text, { replyTo: event.message.message_id }),
690
690
  });
691
691
  if (approvalReply) {
692
692
  const processing = approvalReply.process(async () => {
@@ -851,7 +851,7 @@ export class FeishuHarnessBridge {
851
851
  if (error?.code === 'turn-stopped') {
852
852
  await this.#removeProcessingReaction(messageId, processingReaction);
853
853
  if (error?.batchInputMessage) {
854
- await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
854
+ await this.#send(event.message.chat_id, error.batchInputMessage, { replyTo: event.message.message_id }).catch(() => undefined);
855
855
  }
856
856
  return;
857
857
  }
@@ -940,7 +940,7 @@ export class FeishuHarnessBridge {
940
940
  ]);
941
941
  }
942
942
  for (const reply of result?.messages ?? [result?.message]) {
943
- if (reply) await this.#send(event.message.chat_id, reply);
943
+ if (reply) await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
944
944
  }
945
945
  this.#status.lastError = null;
946
946
  }
@@ -964,7 +964,7 @@ export class FeishuHarnessBridge {
964
964
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
965
965
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
966
966
  if (!text && !hasImages && !hasFiles) {
967
- await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
967
+ await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
968
968
  return;
969
969
  }
970
970
 
@@ -973,11 +973,11 @@ export class FeishuHarnessBridge {
973
973
  return;
974
974
  }
975
975
  if (commandText === '/help') {
976
- await this.#send(event.message.chat_id, menuHelpText());
976
+ await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
977
977
  return;
978
978
  }
979
979
  if (MENU_COMMAND.test(commandText)) {
980
- await this.#sendMenuCard(key, event.message.chat_id);
980
+ await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
981
981
  return;
982
982
  }
983
983
  if (commandText === '/new') {
@@ -985,53 +985,54 @@ export class FeishuHarnessBridge {
985
985
  await this.#send(
986
986
  event.message.chat_id,
987
987
  t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
988
+ { replyTo: event.message.message_id },
988
989
  );
989
990
  return;
990
991
  }
991
992
  await this.#state.clearSession(key);
992
- await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
993
- await this.#sendMenuCard(key, event.message.chat_id);
993
+ await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'), { replyTo: event.message.message_id });
994
+ await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
994
995
  return;
995
996
  }
996
997
  if (commandText === '/status') {
997
- await this.#showStatusText(key, event.message.chat_id);
998
+ await this.#showStatusText(key, event.message.chat_id, event.message.message_id);
998
999
  return;
999
1000
  }
1000
1001
  if (commandText === '/compact') {
1001
1002
  const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
1002
1003
  if (compactCommand) {
1003
- await this.#send(event.message.chat_id, compactCommand.message);
1004
+ await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
1004
1005
  }
1005
1006
  return;
1006
1007
  }
1007
1008
  if (SESSION_LIST_PREFIX.test(commandText)) {
1008
1009
  const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
1009
- await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
1010
+ await this.#showSessions({ chatId: event.message.chat_id, key, replyTo: event.message.message_id }, selector, 0);
1010
1011
  return;
1011
1012
  }
1012
1013
  if (WORKSPACE_LIST_COMMAND.test(commandText)) {
1013
- await this.#showWorkspaces({ chatId: event.message.chat_id, key });
1014
+ await this.#showWorkspaces({ chatId: event.message.chat_id, key, replyTo: event.message.message_id });
1014
1015
  return;
1015
1016
  }
1016
1017
  if (WATCH_COMMAND.test(commandText)) {
1017
1018
  const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
1018
- await this.#runWatch(key, event.message.chat_id, target);
1019
+ await this.#runWatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
1019
1020
  return;
1020
1021
  }
1021
1022
  if (UNWATCH_COMMAND.test(commandText)) {
1022
1023
  const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
1023
- await this.#runUnwatch(key, event.message.chat_id, target);
1024
+ await this.#runUnwatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
1024
1025
  return;
1025
1026
  }
1026
1027
  if (WATCHLIST_COMMAND.test(commandText)) {
1027
- await this.#showWatchList(key, event.message.chat_id);
1028
+ await this.#showWatchList(key, event.message.chat_id, { replyTo: event.message.message_id });
1028
1029
  return;
1029
1030
  }
1030
1031
  if (ARCHIVED_COMMAND.test(commandText)) {
1031
1032
  const match = ARCHIVED_COMMAND.exec(commandText);
1032
1033
  const value = match[1]?.toLowerCase();
1033
1034
  if (value !== 'on' && value !== 'off') {
1034
- await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'));
1035
+ await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'), { replyTo: event.message.message_id });
1035
1036
  return;
1036
1037
  }
1037
1038
  if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
@@ -1040,6 +1041,7 @@ export class FeishuHarnessBridge {
1040
1041
  await this.#send(
1041
1042
  event.message.chat_id,
1042
1043
  value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
1044
+ { replyTo: event.message.message_id },
1043
1045
  );
1044
1046
  return;
1045
1047
  }
@@ -1059,7 +1061,7 @@ export class FeishuHarnessBridge {
1059
1061
  : await runWorkspaceCommand(text, this.#harness, key);
1060
1062
  if (workspaceCommand) {
1061
1063
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
1062
- await this.#send(event.message.chat_id, reply);
1064
+ await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
1063
1065
  }
1064
1066
  return;
1065
1067
  }
@@ -1073,7 +1075,7 @@ export class FeishuHarnessBridge {
1073
1075
  { signal: this.#signal },
1074
1076
  );
1075
1077
  if (compactCommand) {
1076
- await this.#send(event.message.chat_id, compactCommand.message);
1078
+ await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
1077
1079
  return;
1078
1080
  }
1079
1081
 
@@ -2030,7 +2032,7 @@ export class FeishuHarnessBridge {
2030
2032
  }
2031
2033
 
2032
2034
  async #showSessions(
2033
- { chatId, key },
2035
+ { chatId, key, replyTo = null },
2034
2036
  selector,
2035
2037
  page = 0,
2036
2038
  { updateMessageId = null } = {},
@@ -2039,14 +2041,14 @@ export class FeishuHarnessBridge {
2039
2041
  const signal = this.#cardDataSignal();
2040
2042
  const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal });
2041
2043
  if (resolved.error) {
2042
- await this.#send(chatId, resolved.error);
2044
+ await this.#send(chatId, resolved.error, { replyTo });
2043
2045
  return;
2044
2046
  }
2045
2047
  const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
2046
2048
  const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
2047
2049
  const workspace = listed?.workspace ?? resolved.workspace;
2048
2050
  if (sessions.length === 0) {
2049
- await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }));
2051
+ await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }), { replyTo });
2050
2052
  return;
2051
2053
  }
2052
2054
  const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
@@ -2065,6 +2067,7 @@ export class FeishuHarnessBridge {
2065
2067
  {
2066
2068
  key,
2067
2069
  updateMessageId,
2070
+ replyTo,
2068
2071
  // Keep the canonical selector result for later page callbacks. The
2069
2072
  // list response's workspace is display data and is not authoritative.
2070
2073
  sessionWorkspace: resolved.workspace,
@@ -2076,7 +2079,7 @@ export class FeishuHarnessBridge {
2076
2079
  }
2077
2080
  }
2078
2081
 
2079
- async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) {
2082
+ async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
2080
2083
  try {
2081
2084
  const { current, paths } = await workspacePathSnapshot(
2082
2085
  this.#harness,
@@ -2086,7 +2089,7 @@ export class FeishuHarnessBridge {
2086
2089
  await this.#sendCard(
2087
2090
  chatId,
2088
2091
  workspaceListCard(paths, current),
2089
- { key, updateMessageId },
2092
+ { key, updateMessageId, replyTo },
2090
2093
  );
2091
2094
  } catch (error) {
2092
2095
  await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
@@ -2144,6 +2147,7 @@ export class FeishuHarnessBridge {
2144
2147
 
2145
2148
  async #sendCard(chatId, cardJson, options = {}) {
2146
2149
  const updateMessageId = nonEmptyString(options.updateMessageId);
2150
+ const replyTo = nonEmptyString(options.replyTo);
2147
2151
 
2148
2152
  if (updateMessageId) {
2149
2153
  try {
@@ -2161,6 +2165,28 @@ export class FeishuHarnessBridge {
2161
2165
  }
2162
2166
  }
2163
2167
 
2168
+ // A brand-new card triggered by an inbound message is delivered as a
2169
+ // threaded reply so it lands inside the same Feishu topic. Falls back to
2170
+ // a plain chat message when the referenced message is gone.
2171
+ const content = cardJson;
2172
+ if (replyTo) {
2173
+ try {
2174
+ const response = await this.#client.im.v1.message.reply({
2175
+ path: { message_id: replyTo },
2176
+ data: { msg_type: 'interactive', content },
2177
+ });
2178
+ if (response?.code && response.code !== 0) {
2179
+ throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
2180
+ }
2181
+ const repliedMessageId = nonEmptyString(response?.data?.message_id);
2182
+ if (repliedMessageId) {
2183
+ this.#rememberCardRoute(repliedMessageId, chatId, options);
2184
+ return repliedMessageId;
2185
+ }
2186
+ } catch (error) {
2187
+ this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
2188
+ }
2189
+ }
2164
2190
  const response = await this.#client.im.v1.message.create({
2165
2191
  params: { receive_id_type: 'chat_id' },
2166
2192
  data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
@@ -2173,7 +2199,7 @@ export class FeishuHarnessBridge {
2173
2199
  return messageId;
2174
2200
  }
2175
2201
 
2176
- async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) {
2202
+ async #sendMenuCard(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2177
2203
  let currentSessionId = null;
2178
2204
  let directSessionTitle = null;
2179
2205
  try {
@@ -2268,7 +2294,7 @@ export class FeishuHarnessBridge {
2268
2294
  currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
2269
2295
  sessions, archiveVisible, presetCatalog, modelCatalog,
2270
2296
  }),
2271
- { key, updateMessageId },
2297
+ { key, updateMessageId, replyTo },
2272
2298
  );
2273
2299
  }
2274
2300
 
@@ -2350,7 +2376,7 @@ export class FeishuHarnessBridge {
2350
2376
  /**
2351
2377
  * Gather system status and show the status card.
2352
2378
  */
2353
- async #showStatusText(key, chatId) {
2379
+ async #showStatusText(key, chatId, replyTo = null) {
2354
2380
  try {
2355
2381
  await this.#harness.ensureRunning({ signal: this.#signal });
2356
2382
  const lines = [t('连接正常')];
@@ -2369,7 +2395,7 @@ export class FeishuHarnessBridge {
2369
2395
  : (settings.agentPreset || t('跟随默认')),
2370
2396
  }));
2371
2397
  }
2372
- await this.#send(chatId, lines.join('\n'));
2398
+ await this.#send(chatId, lines.join('\n'), { replyTo });
2373
2399
  } catch (error) {
2374
2400
  await this.#sendFailure(chatId, error, { logLabel: 'status text' });
2375
2401
  }
@@ -2787,11 +2813,12 @@ export class FeishuHarnessBridge {
2787
2813
  notify = true,
2788
2814
  validatedTarget = null,
2789
2815
  workspaceHint = null,
2816
+ replyTo = null,
2790
2817
  } = {}) {
2791
2818
  const watchRequestedAt = Date.now();
2792
2819
  const reply = async (message) => {
2793
2820
  if (!notify) return;
2794
- await this.#send(chatId, message).catch((error) => {
2821
+ await this.#send(chatId, message, { replyTo }).catch((error) => {
2795
2822
  this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message);
2796
2823
  });
2797
2824
  };
@@ -2854,10 +2881,10 @@ export class FeishuHarnessBridge {
2854
2881
  return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) };
2855
2882
  }
2856
2883
 
2857
- async #runUnwatch(key, chatId, target, { notify = true } = {}) {
2884
+ async #runUnwatch(key, chatId, target, { notify = true, replyTo = null } = {}) {
2858
2885
  const reply = async (message) => {
2859
2886
  if (!notify) return;
2860
- await this.#send(chatId, message).catch((error) => {
2887
+ await this.#send(chatId, message, { replyTo }).catch((error) => {
2861
2888
  this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message);
2862
2889
  });
2863
2890
  };
@@ -2883,7 +2910,7 @@ export class FeishuHarnessBridge {
2883
2910
  return { ok: true, changed: true, entry };
2884
2911
  }
2885
2912
 
2886
- async #showWatchList(key, chatId, { updateMessageId = null } = {}) {
2913
+ async #showWatchList(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2887
2914
  const entries = this.#state.watchEntries?.(key) ?? [];
2888
2915
  // 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
2889
2916
  let availableSessions = [];
@@ -2913,6 +2940,7 @@ export class FeishuHarnessBridge {
2913
2940
  {
2914
2941
  key,
2915
2942
  updateMessageId,
2943
+ replyTo,
2916
2944
  sessionWorkspace: currentWorkspace,
2917
2945
  },
2918
2946
  );
@@ -3068,17 +3096,18 @@ export class FeishuHarnessBridge {
3068
3096
  actor: senderOpenId(event),
3069
3097
  chatId: event.message.chat_id,
3070
3098
  requiresMention: event.message.chat_type !== 'p2p',
3099
+ replyToMessageId: event.message.message_id,
3071
3100
  }),
3072
3101
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
3073
3102
  files,
3074
3103
  };
3075
3104
  }
3076
3105
 
3077
- async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
3106
+ async #sendAnswerText(chatId, answer, { deliveryId, presentation, replyTo = null }) {
3078
3107
  const providerMessageIds = [];
3079
3108
  for (const chunk of splitText(answer)) {
3080
3109
  this.#signal?.throwIfAborted();
3081
- const messageId = await this.#send(chatId, chunk);
3110
+ const messageId = await this.#send(chatId, chunk, { replyTo });
3082
3111
  if (messageId) providerMessageIds.push(messageId);
3083
3112
  }
3084
3113
  return createDeliveryReceipt({
@@ -3182,6 +3211,7 @@ export class FeishuHarnessBridge {
3182
3211
  {
3183
3212
  deliveryId: messageId,
3184
3213
  presentation: 'feishu-text',
3214
+ replyTo: messageId,
3185
3215
  },
3186
3216
  );
3187
3217
  } catch (error) {
@@ -3252,6 +3282,7 @@ export class FeishuHarnessBridge {
3252
3282
  {
3253
3283
  deliveryId: messageId,
3254
3284
  presentation: 'feishu-text-fallback',
3285
+ replyTo: messageId,
3255
3286
  },
3256
3287
  );
3257
3288
  } catch (fallbackError) {
@@ -3302,6 +3333,7 @@ export class FeishuHarnessBridge {
3302
3333
  {
3303
3334
  deliveryId: messageId,
3304
3335
  presentation: 'feishu-text-fallback',
3336
+ replyTo: messageId,
3305
3337
  },
3306
3338
  );
3307
3339
  } catch (fallbackError) {
@@ -3368,11 +3400,11 @@ export class FeishuHarnessBridge {
3368
3400
  const pending = this.#pendingInteractions.get(key);
3369
3401
  if (!pending || pending !== expected || pending.submitting) {
3370
3402
  if (this.#isResolvedQuestionReply(event, key)) {
3371
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3403
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3372
3404
  return;
3373
3405
  }
3374
3406
  if (claimed && (!pending || pending !== expected)) {
3375
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
3407
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id });
3376
3408
  return;
3377
3409
  }
3378
3410
  return this.#enqueueMessage(event, messageId, key, processingReaction, {
@@ -3430,7 +3462,7 @@ export class FeishuHarnessBridge {
3430
3462
  if (error?.code === 'interaction-not-pending') {
3431
3463
  this.#rememberResolvedInteraction(key, pending);
3432
3464
  this.#clearPendingInteraction(key, pending.interactionId);
3433
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3465
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3434
3466
  return;
3435
3467
  }
3436
3468
  pending.submitting = false;
@@ -3448,12 +3480,13 @@ export class FeishuHarnessBridge {
3448
3480
  actor,
3449
3481
  chatId,
3450
3482
  requiresMention,
3483
+ replyToMessageId,
3451
3484
  }) {
3452
3485
  if (await this.#approvals.handleRequested(interaction, {
3453
3486
  key,
3454
3487
  actor,
3455
3488
  requiresMention,
3456
- send: (text) => this.#send(chatId, text),
3489
+ send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
3457
3490
  })) return;
3458
3491
 
3459
3492
  // Approval requests return above; the existing question state machine stays unchanged.
@@ -3484,6 +3517,7 @@ export class FeishuHarnessBridge {
3484
3517
  await this.#send(
3485
3518
  chatId,
3486
3519
  t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
3520
+ { replyTo: replyToMessageId },
3487
3521
  ).catch(() => undefined);
3488
3522
  return;
3489
3523
  }
@@ -3519,6 +3553,7 @@ export class FeishuHarnessBridge {
3519
3553
  answers: [],
3520
3554
  index: 0,
3521
3555
  chatId,
3556
+ replyToMessageId,
3522
3557
  queue: null,
3523
3558
  claimedReplyMessageId: null,
3524
3559
  submitting: false,
@@ -3553,6 +3588,9 @@ export class FeishuHarnessBridge {
3553
3588
  pending.questions.length,
3554
3589
  { requiresMention: pending.requiresMention },
3555
3590
  ),
3591
+ // Reply to the message that started the turn so the question lands in
3592
+ // the same Feishu thread/topic instead of the group's default area.
3593
+ { replyTo: pending.replyToMessageId },
3556
3594
  );
3557
3595
  if (messageId) {
3558
3596
  pending.questionMessageIds.add(messageId);
@@ -3585,7 +3623,7 @@ export class FeishuHarnessBridge {
3585
3623
  await this.#state.markSeen(messageId);
3586
3624
  this.#status.lastMessageAt = new Date().toISOString();
3587
3625
  this.#status.messagesReceived += 1;
3588
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3626
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3589
3627
  }
3590
3628
 
3591
3629
  #takePendingInteraction(key, interactionId) {
@@ -3651,13 +3689,33 @@ export class FeishuHarnessBridge {
3651
3689
  else processingReaction.success();
3652
3690
  }
3653
3691
 
3654
- async #send(chatId, text) {
3692
+ async #send(chatId, text, { replyTo } = {}) {
3693
+ const content = JSON.stringify({ text });
3694
+ if (replyTo) {
3695
+ try {
3696
+ const response = await this.#client.im.v1.message.reply({
3697
+ path: { message_id: replyTo },
3698
+ data: { msg_type: 'text', content },
3699
+ });
3700
+ if (response?.code && response.code !== 0) {
3701
+ throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
3702
+ }
3703
+ return nonEmptyString(response?.data?.message_id);
3704
+ } catch (error) {
3705
+ // The referenced message may be gone (recalled/deleted); keep the
3706
+ // delivery promise by falling back to a plain chat message.
3707
+ this.#logger.warn?.(
3708
+ '[dsh-feishu] threaded reply failed; falling back to a plain message:',
3709
+ error?.message ?? String(error),
3710
+ );
3711
+ }
3712
+ }
3655
3713
  const response = await this.#client.im.v1.message.create({
3656
3714
  params: { receive_id_type: 'chat_id' },
3657
3715
  data: {
3658
3716
  receive_id: chatId,
3659
3717
  msg_type: 'text',
3660
- content: JSON.stringify({ text }),
3718
+ content,
3661
3719
  },
3662
3720
  });
3663
3721
  if (response?.code && response.code !== 0) {
@@ -16,6 +16,11 @@ export function conversationKey(event) {
16
16
  }
17
17
  const chatId = event?.message?.chat_id;
18
18
  if (!chatId) throw new Error('Feishu group event has no chat id');
19
+ // Topic groups: every message belongs to a thread, so key the session per
20
+ // thread to keep each topic's Harness conversation isolated. Regular group
21
+ // chats carry no thread_id and keep the single shared `group:<chat_id>` key.
22
+ const threadId = event?.message?.thread_id;
23
+ if (typeof threadId === 'string' && threadId.trim()) return `group:${chatId}:thread:${threadId}`;
19
24
  return `group:${chatId}`;
20
25
  }
21
26
 
@@ -1,4 +1,4 @@
1
- import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
1
+ import { QQBot, contentSanitizer, typingIndicator } from '@tencent-connect/qqbot-nodejs';
2
2
 
3
3
  import {
4
4
  connectionTestTarget,
@@ -171,6 +171,10 @@ export class QqRuntime {
171
171
  replyTimeoutMs: this.#replyTimeoutMs,
172
172
  signal: controller.signal,
173
173
  });
174
+ // QQ delivers emoji/face messages as opaque `<faceType=..,faceId="..",ext="..">`
175
+ // tags. Parse them into readable text so the Harness sees what the sender
176
+ // actually meant instead of an unusable markup fragment.
177
+ bot.use(contentSanitizer({ parseFaceTags: true }));
174
178
  bot.use?.(this.#typingMiddleware({
175
179
  keepAlive: true,
176
180
  predicate: (ctx) => this.#config.ownerUserOpenid === '*'
@@ -4,6 +4,12 @@ export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
4
4
  ]);
5
5
 
6
6
  export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
7
+ export const CONTEXT_GROUP_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
8
+ 当前消息来自群聊,请使用严肃、克制、简洁的表达方式。`;
9
+ export const CONTEXT_DIRECT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
10
+ 当前消息来自私聊,可以使用更轻松、幽默、详细的表达方式。`;
11
+
12
+ // Kept for integrations that imported the original combined example.
7
13
  export const CONTEXT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
8
14
  conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑`;
9
15
 
@@ -11,13 +17,21 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
11
17
  export const DEFAULT_CONTEXT_GUIDANCE = CONTEXT_GUIDANCE_EXAMPLE;
12
18
 
13
19
  export const DEFAULT_CONTEXT_ENHANCEMENT_CONFIG = Object.freeze({
14
- groupEnabled: false,
15
- directEnabled: false,
16
- fields: Object.freeze(['senderId']),
17
- guidance: '',
20
+ group: Object.freeze({
21
+ enabled: false,
22
+ fields: Object.freeze(['senderId']),
23
+ guidance: '',
24
+ }),
25
+ direct: Object.freeze({
26
+ enabled: false,
27
+ fields: Object.freeze(['senderId']),
28
+ guidance: '',
29
+ }),
18
30
  });
19
31
 
20
- const CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
32
+ const CONFIG_KEYS = ['group', 'direct'];
33
+ const SCOPE_KEYS = ['enabled', 'fields', 'guidance'];
34
+ const LEGACY_CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
21
35
  const CHANNELS = new Set([
22
36
  'wecom', 'weixin', 'feishu', 'dingtalk', 'qq',
23
37
  'slack', 'telegram', 'discord', 'whatsapp',
@@ -31,16 +45,19 @@ function invalidConfig(message) {
31
45
  return error;
32
46
  }
33
47
 
34
- /** Validate the complete atomic save, preserving explicit empty selections/text. */
35
- export function validateContextEnhancementConfig(input) {
36
- if (!input || typeof input !== 'object' || Array.isArray(input)
37
- || ![Object.prototype, null].includes(Object.getPrototypeOf(input))
38
- || Reflect.ownKeys(input).length !== CONFIG_KEYS.length
39
- || !CONFIG_KEYS.every((key) => Object.hasOwn(input, key))) {
48
+ function hasExactKeys(input, keys) {
49
+ return input && typeof input === 'object' && !Array.isArray(input)
50
+ && [Object.prototype, null].includes(Object.getPrototypeOf(input))
51
+ && Reflect.ownKeys(input).length === keys.length
52
+ && keys.every((key) => Object.hasOwn(input, key));
53
+ }
54
+
55
+ function validateContextEnhancementScope(input) {
56
+ if (!hasExactKeys(input, SCOPE_KEYS)) {
40
57
  throw invalidConfig('请提交完整的上下文增强设置。');
41
58
  }
42
- const { groupEnabled, directEnabled, fields, guidance } = input;
43
- if (typeof groupEnabled !== 'boolean' || typeof directEnabled !== 'boolean') {
59
+ const { enabled, fields, guidance } = input;
60
+ if (typeof enabled !== 'boolean') {
44
61
  throw invalidConfig('群聊和私聊开关必须是布尔值。');
45
62
  }
46
63
  if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
@@ -50,19 +67,51 @@ export function validateContextEnhancementConfig(input) {
50
67
  throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
51
68
  }
52
69
  return Object.freeze({
53
- groupEnabled,
54
- directEnabled,
70
+ enabled,
55
71
  fields: Object.freeze(CONTEXT_ENHANCEMENT_FIELDS.filter((field) => fields.includes(field))),
56
72
  guidance: guidance.trim() ? guidance : '',
57
73
  });
58
74
  }
59
75
 
76
+ /** Validate the complete atomic save, preserving explicit empty selections/text. */
77
+ export function validateContextEnhancementConfig(input) {
78
+ if (!hasExactKeys(input, CONFIG_KEYS)) {
79
+ throw invalidConfig('请提交完整的上下文增强设置。');
80
+ }
81
+ return Object.freeze({
82
+ group: validateContextEnhancementScope(input.group),
83
+ direct: validateContextEnhancementScope(input.direct),
84
+ });
85
+ }
86
+
87
+ function migrateLegacyContextEnhancementConfig(input) {
88
+ if (!hasExactKeys(input, LEGACY_CONFIG_KEYS)) {
89
+ throw invalidConfig('请提交完整的上下文增强设置。');
90
+ }
91
+ return validateContextEnhancementConfig({
92
+ group: {
93
+ enabled: input.groupEnabled,
94
+ fields: input.fields,
95
+ guidance: input.guidance,
96
+ },
97
+ direct: {
98
+ enabled: input.directEnabled,
99
+ fields: input.fields,
100
+ guidance: input.guidance,
101
+ },
102
+ });
103
+ }
104
+
60
105
  /** Missing or damaged enhancement settings must never break an existing bot. */
61
106
  export function normalizeContextEnhancementConfig(input) {
62
107
  try {
63
108
  return validateContextEnhancementConfig(input);
64
109
  } catch {
65
- return DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
110
+ try {
111
+ return migrateLegacyContextEnhancementConfig(input);
112
+ } catch {
113
+ return DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
114
+ }
66
115
  }
67
116
  }
68
117
 
@@ -71,11 +120,15 @@ export function captureContextEnhancement(provider, conversationType) {
71
120
  if (conversationType !== 'group' && conversationType !== 'direct') return null;
72
121
  try {
73
122
  const settings = provider?.getSettings?.();
74
- const enabledKey = conversationType === 'group' ? 'groupEnabled' : 'directEnabled';
75
- if (settings?.[enabledKey] !== true) return null;
123
+ const legacyEnabledKey = conversationType === 'group' ? 'groupEnabled' : 'directEnabled';
124
+ const enabled = Object.hasOwn(settings ?? {}, conversationType)
125
+ ? settings?.[conversationType]?.enabled
126
+ : settings?.[legacyEnabledKey];
127
+ if (enabled !== true) return null;
76
128
  const config = normalizeContextEnhancementConfig(settings);
77
- if (config[enabledKey] !== true) return null;
78
- return Object.freeze({ config, botId: provider.botId, conversationType });
129
+ const scope = config[conversationType];
130
+ if (scope.enabled !== true) return null;
131
+ return Object.freeze({ config: scope, botId: provider.botId, conversationType });
79
132
  } catch {
80
133
  return null;
81
134
  }
@@ -105,19 +105,23 @@ export const COMMANDS_MENU_BUTTON = Object.freeze({ type: 'commands' });
105
105
  export class TelegramApi {
106
106
  #token;
107
107
  #fetch;
108
+ #FormDataImpl;
108
109
  #baseUrl;
109
110
  #fileUploadTimeoutMs;
110
111
 
111
112
  constructor({
112
113
  token,
113
114
  fetchImpl = fetch,
115
+ FormDataImpl = globalThis.FormData,
114
116
  baseUrl = DEFAULT_BASE_URL,
115
117
  fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
116
118
  }) {
117
119
  if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
118
120
  if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
121
+ if (typeof FormDataImpl !== 'function') throw new TypeError('TelegramApi requires FormData');
119
122
  this.#token = token.trim();
120
123
  this.#fetch = fetchImpl;
124
+ this.#FormDataImpl = FormDataImpl;
121
125
  this.#baseUrl = new URL(baseUrl);
122
126
  this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
123
127
  }
@@ -273,7 +277,7 @@ export class TelegramApi {
273
277
  || !Buffer.isBuffer(file.bytes)) {
274
278
  throw new TypeError(`A Telegram ${mediaLabel} is required`);
275
279
  }
276
- const payload = new FormData();
280
+ const payload = new this.#FormDataImpl();
277
281
  payload.append('chat_id', String(chatId));
278
282
  payload.append(
279
283
  fieldName,