@xmanrui/dsh-im 4.8.0 → 4.9.1

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 (32) hide show
  1. package/README.en.md +8 -79
  2. package/README.md +8 -79
  3. package/lib/client.js +73 -20
  4. package/lib/index.js +244 -240
  5. package/package.json +10 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/i18n.js +4 -0
  10. package/plugin-src/client/styles.js +3 -2
  11. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  12. package/plugin-src/host/modern-harness-api.mjs +17 -8
  13. package/src/channels/dingtalk/connection-error.mjs +299 -0
  14. package/src/channels/dingtalk/dingtalk-bridge.mjs +1 -0
  15. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  16. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  17. package/src/channels/feishu/bridge.mjs +174 -20
  18. package/src/channels/feishu/feishu-cards.mjs +69 -0
  19. package/src/channels/feishu/feishu-runtime.mjs +5 -0
  20. package/src/channels/feishu/slash-command-registry.mjs +3 -0
  21. package/src/channels/qq/qq-bridge.mjs +1 -0
  22. package/src/channels/shared/harness-approval.mjs +24 -1
  23. package/src/channels/shared/harness-client.mjs +45 -16
  24. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  25. package/src/channels/shared/i18n-en/feishu.mjs +11 -0
  26. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  27. package/src/channels/shared/semantic/reply-reference.mjs +5 -11
  28. package/src/channels/shared/text-harness-bridge.mjs +1 -0
  29. package/src/channels/shared/workspace-command.mjs +2 -2
  30. package/src/channels/telegram/telegram-runtime.mjs +6 -9
  31. package/src/channels/wecom/wecom-bridge.mjs +1 -0
  32. package/src/channels/weixin/weixin-bridge.mjs +1 -0
@@ -12,6 +12,11 @@ import {
12
12
  } from '../shared/connection-test.mjs';
13
13
  import { t } from '../shared/i18n.mjs';
14
14
  import { publicMessageFailure } from '../shared/message-failure.mjs';
15
+ import {
16
+ describeDingtalkConnectionFailure,
17
+ dingtalkPublicConnectionError,
18
+ dingtalkRuntimeStartError,
19
+ } from './connection-error.mjs';
15
20
 
16
21
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
17
22
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@@ -216,12 +221,14 @@ export class DingtalkController {
216
221
  try {
217
222
  await this.#startRuntime(latest, clientSecret);
218
223
  this.#errors.delete(latest.botId);
219
- } catch {
220
- this.#errors.set(
221
- latest.botId,
222
- safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
223
- );
224
- this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
224
+ } catch (error) {
225
+ this.#rememberConnectionFailure({
226
+ config: latest,
227
+ clientSecret,
228
+ error,
229
+ fallbackMessage: '钉钉连接未就绪,请稍后重试。',
230
+ context: `bot ${latest.botId} failed to initialize`,
231
+ });
225
232
  }
226
233
  this.#touch();
227
234
  });
@@ -318,12 +325,14 @@ export class DingtalkController {
318
325
  try {
319
326
  await this.#startRuntime(config, normalizedSecret);
320
327
  this.#errors.delete(identity.botId);
321
- } catch {
322
- this.#errors.set(
323
- identity.botId,
324
- safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
325
- );
326
- this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
328
+ } catch (error) {
329
+ this.#rememberConnectionFailure({
330
+ config,
331
+ clientSecret: normalizedSecret,
332
+ error,
333
+ fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
334
+ context: `credential-bound bot ${identity.botId} is not ready`,
335
+ });
327
336
  }
328
337
  this.#touch();
329
338
  });
@@ -380,11 +389,14 @@ export class DingtalkController {
380
389
  await this.#startRuntime(config, clientSecret);
381
390
  this.#errors.delete(botId);
382
391
  } catch (error) {
383
- this.#errors.set(
384
- botId,
385
- safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
386
- );
387
- throw error;
392
+ const publicError = this.#rememberConnectionFailure({
393
+ config,
394
+ clientSecret,
395
+ error,
396
+ fallbackMessage: '钉钉连接仍未就绪,请稍后重试。',
397
+ context: `bot ${botId} failed to reconnect`,
398
+ });
399
+ throw dingtalkPublicConnectionError(publicError, error);
388
400
  } finally {
389
401
  this.#touch();
390
402
  }
@@ -675,11 +687,13 @@ export class DingtalkController {
675
687
  await rollback();
676
688
  throw abortError();
677
689
  }
678
- this.#errors.set(
679
- identity.botId,
680
- safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
681
- );
682
- this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
690
+ this.#rememberConnectionFailure({
691
+ config,
692
+ clientSecret,
693
+ error,
694
+ fallbackMessage: '钉钉已接入,但消息连接暂未就绪,请稍后重试。',
695
+ context: `authorized bot ${identity.botId} is not ready`,
696
+ });
683
697
  }
684
698
  return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
685
699
  });
@@ -696,11 +710,14 @@ export class DingtalkController {
696
710
  } catch (error) {
697
711
  await this.#configStore.save(previousConfig).catch(() => undefined);
698
712
  await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
699
- this.#errors.set(
700
- previousConfig.botId,
701
- safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
702
- );
703
- throw error;
713
+ const publicError = this.#rememberConnectionFailure({
714
+ config: previousConfig,
715
+ clientSecret,
716
+ error,
717
+ fallbackMessage: '钉钉连接未就绪,请稍后重试。',
718
+ context: `bot ${previousConfig.botId} failed to apply updated settings`,
719
+ });
720
+ throw dingtalkPublicConnectionError(publicError, error);
704
721
  } finally {
705
722
  this.#touch();
706
723
  }
@@ -711,13 +728,21 @@ export class DingtalkController {
711
728
  if (this.#closed) throw abortError();
712
729
  await this.#stopRuntime(config.botId);
713
730
  if (this.#closed) throw abortError();
714
- const runtime = await this.#createRuntime({
715
- botId: config.botId,
716
- config: structuredClone(config),
717
- clientSecret,
718
- });
731
+ let runtime;
732
+ try {
733
+ runtime = await this.#createRuntime({
734
+ botId: config.botId,
735
+ config: structuredClone(config),
736
+ clientSecret,
737
+ });
738
+ } catch (error) {
739
+ throw dingtalkRuntimeStartError('dingtalk-runtime-prepare-failed', error);
740
+ }
719
741
  if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
720
- throw new TypeError('createRuntime returned an invalid DingTalk runtime');
742
+ throw dingtalkRuntimeStartError(
743
+ 'dingtalk-runtime-prepare-failed',
744
+ new TypeError('createRuntime returned an invalid DingTalk runtime'),
745
+ );
721
746
  }
722
747
  if (this.#closed) {
723
748
  await runtime.stop().catch(() => undefined);
@@ -733,10 +758,30 @@ export class DingtalkController {
733
758
  } catch (error) {
734
759
  if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
735
760
  await runtime.stop().catch(() => undefined);
736
- throw error;
761
+ throw dingtalkRuntimeStartError('dingtalk-stream-connect-failed', error);
737
762
  }
738
763
  }
739
764
 
765
+ #rememberConnectionFailure({
766
+ config,
767
+ clientSecret,
768
+ error,
769
+ fallbackMessage,
770
+ context,
771
+ }) {
772
+ const failure = describeDingtalkConnectionFailure(error, {
773
+ fallbackMessage,
774
+ clientId: config.clientId,
775
+ clientSecret,
776
+ });
777
+ this.#errors.set(config.botId, failure.publicError);
778
+ this.#logger.error?.(
779
+ `[dsh-dingtalk] ${context} [${failure.publicError.referenceId}]`,
780
+ failure.diagnostic,
781
+ );
782
+ return failure.publicError;
783
+ }
784
+
740
785
  async #stopRuntime(botId) {
741
786
  const runtime = this.#runtimes.get(botId);
742
787
  this.#runtimes.delete(botId);
@@ -6,6 +6,7 @@ import {
6
6
  import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
7
  import { t } from '../shared/i18n.mjs';
8
8
  import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
9
+ import { dingtalkRuntimeStartError } from './connection-error.mjs';
9
10
 
10
11
  function nonEmptyString(value) {
11
12
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -223,10 +224,12 @@ export class DingtalkRuntime {
223
224
  this.#status.startedAt = new Date().toISOString();
224
225
  this.#status.dingtalkStreamState = 'connecting';
225
226
  this.#status.lastError = null;
227
+ let startStage = 'dingtalk-harness-connect-failed';
226
228
 
227
229
  try {
228
230
  await this.#harness.ensureRunning({ signal });
229
231
  this.#status.harnessReachable = true;
232
+ startStage = 'dingtalk-runtime-prepare-failed';
230
233
  if (typeof this.#state.removePendingSenderByStaffId === 'function') {
231
234
  for (const staffId of approvedSenderIds(this.#config)) {
232
235
  await this.#state.removePendingSenderByStaffId(staffId);
@@ -249,6 +252,7 @@ export class DingtalkRuntime {
249
252
  signal,
250
253
  });
251
254
 
255
+ startStage = 'dingtalk-stream-client-load-failed';
252
256
  const created = await this.#streamFactory({
253
257
  clientId: this.#config.clientId,
254
258
  clientSecret: this.#clientSecret,
@@ -266,6 +270,7 @@ export class DingtalkRuntime {
266
270
 
267
271
  const client = this.#client;
268
272
  const bridge = this.#bridge;
273
+ startStage = 'dingtalk-stream-listener-failed';
269
274
  client.registerCallbackListener(this.#topic, (response) => {
270
275
  if (this.#client !== client || this.#bridge !== bridge) return;
271
276
  const callbackMessageId = nonEmptyString(response?.headers?.messageId);
@@ -314,6 +319,7 @@ export class DingtalkRuntime {
314
319
  this.#callbackTasks.add(task);
315
320
  });
316
321
 
322
+ startStage = 'dingtalk-stream-connect-failed';
317
323
  await connectStream(
318
324
  client,
319
325
  this.#connectTimeoutMs,
@@ -336,11 +342,12 @@ export class DingtalkRuntime {
336
342
  return this.status;
337
343
  } catch (error) {
338
344
  const aborted = signal.aborted;
345
+ const failure = aborted ? error : dingtalkRuntimeStartError(startStage, error);
339
346
  this.#status.ready = false;
340
347
  this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
341
- this.#status.lastError = aborted ? null : (error?.message ?? String(error));
348
+ this.#status.lastError = aborted ? null : (failure?.message ?? String(failure));
342
349
  await this.stop({ preserveError: !aborted });
343
- throw error;
350
+ throw failure;
344
351
  }
345
352
  }
346
353
 
@@ -76,6 +76,7 @@ import {
76
76
  MENU_PAGE_SIZE,
77
77
  PRESET_FOLLOW_DEFAULT_SENTINEL,
78
78
  STEER_CUSTOM_SENTINEL,
79
+ approvalCard,
79
80
  completionCard,
80
81
  customSteerCard,
81
82
  helpCard,
@@ -83,6 +84,7 @@ import {
83
84
  menuHelpText,
84
85
  modelCard,
85
86
  presetCard,
87
+ questionCard,
86
88
  sessionListCard,
87
89
  statusCard,
88
90
  steerCard,
@@ -107,7 +109,7 @@ const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
107
109
  const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
108
110
  const WATCHLIST_COMMAND = /^\/watchlist$/i;
109
111
  const SESSION_LIST_PREFIX = /^\/(?:sessionlist|sessions)(?:\s|$)/i;
110
- const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
112
+ const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)$/i;
111
113
  const NUMBER_REPLY = /^\d{1,2}$/;
112
114
  /** A displayed menu stays number-tappable for this long. */
113
115
  const MENU_TTL_MS = 10 * 60_000;
@@ -146,7 +148,34 @@ const REPAIR_URL_HOSTS = new Set([
146
148
 
147
149
  const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
148
150
  /** Matches fast card commands that should not be queued behind a running task. */
149
- const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
151
+ const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|workspaces|wsl|watchlist|archived(?:\s+(on|off))?)$/i;
152
+
153
+ /** Pretty-print a tool call's arguments for an approval card. */
154
+ function operationArguments(toolCall) {
155
+ const source = toolCall?.arguments;
156
+ if (source !== null && typeof source === 'object') {
157
+ try {
158
+ return JSON.stringify(source, null, 2);
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ if (typeof source !== 'string') return null;
164
+ const raw = printableText(source);
165
+ // Harness treats an empty tool argument string as an empty object.
166
+ if (!raw) return source === '' ? '{}' : null;
167
+ try {
168
+ return JSON.stringify(JSON.parse(raw), null, 2);
169
+ } catch {
170
+ return raw;
171
+ }
172
+ }
173
+
174
+ /** Strip control characters so the approval card text stays clean. */
175
+ function printableText(value) {
176
+ return String(value ?? '')
177
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
178
+ }
150
179
 
151
180
  function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
152
181
  if (hasImages || hasFiles || typeof text !== 'string') return false;
@@ -471,6 +500,8 @@ export class FeishuHarnessBridge {
471
500
  /** Earliest completion that still needs delivery for each watch. */
472
501
  #failedWatchSeqs = new Map();
473
502
  #cardDataTimeoutMs;
503
+ /** When true, approval/question interactions render as Feishu cards (buttons). */
504
+ #interactionCards = true;
474
505
 
475
506
  constructor({
476
507
  client,
@@ -490,6 +521,7 @@ export class FeishuHarnessBridge {
490
521
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
491
522
  cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
492
523
  replyTimeoutMs = 600_000,
524
+ interactionCards = true,
493
525
  logger = console,
494
526
  signal,
495
527
  }) {
@@ -528,6 +560,7 @@ export class FeishuHarnessBridge {
528
560
  this.#repairLinkWaitMs = repairLinkWaitMs;
529
561
  this.#cardDataTimeoutMs = cardDataTimeoutMs;
530
562
  this.#replyTimeoutMs = replyTimeoutMs;
563
+ this.#interactionCards = interactionCards === true;
531
564
  this.#logger = logger;
532
565
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
533
566
  this.#signal = signal;
@@ -1644,10 +1677,15 @@ export class FeishuHarnessBridge {
1644
1677
  }
1645
1678
  const conversationType = route.key.startsWith('p2p:') ? 'direct'
1646
1679
  : route.key.startsWith('group:') ? 'group' : null;
1680
+ const isInteractionResponse = resolvedAction.startsWith('approve:')
1681
+ || resolvedAction.startsWith('reject:')
1682
+ || resolvedAction.startsWith('answer:');
1647
1683
  const access = evaluateInboundAccess(this.#accessPolicy, {
1648
1684
  conversationType,
1649
1685
  senderIds: operatorOpenId,
1650
- isCommand: true,
1686
+ // These buttons are the card equivalent of an ordinary approval or
1687
+ // question reply. Every other card action remains command-gated.
1688
+ isCommand: !isInteractionResponse,
1651
1689
  });
1652
1690
  if (!access.allowed) {
1653
1691
  if (access.reason === 'command-not-allowed') {
@@ -1710,7 +1748,7 @@ export class FeishuHarnessBridge {
1710
1748
  return;
1711
1749
  }
1712
1750
  }
1713
- await this.#handleCardAction(resolvedAction, entry);
1751
+ await this.#handleCardAction(resolvedAction, { ...entry, actor: entry.operatorOpenId });
1714
1752
  }, {
1715
1753
  lane: isStop || isRealSteer ? 'control' : 'regular',
1716
1754
  coalesceStop: isStop,
@@ -1868,10 +1906,49 @@ export class FeishuHarnessBridge {
1868
1906
  sessionPage = 0,
1869
1907
  sessionLimit = null,
1870
1908
  selections = [],
1909
+ actor = null,
1871
1910
  }) {
1872
1911
  // Confirmations triggered by a card interaction stay anchored to the
1873
1912
  // card's message so they land inside the same Feishu topic.
1874
1913
  const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
1914
+ // Approval card buttons: approve:<approvalId> / reject:<approvalId>
1915
+ if (action.startsWith('approve:') || action.startsWith('reject:')) {
1916
+ const sep = action.indexOf(':');
1917
+ const approvalId = action.slice(sep + 1);
1918
+ const outcome = action.startsWith('approve:') ? 'allowed-once' : 'rejected';
1919
+ // Bind the decision to the operator so another allowed group member
1920
+ // cannot decide someone else's approval.
1921
+ const submitted = await this.#approvals.submitByApprovalId(approvalId, outcome, { actor });
1922
+ if (!submitted) {
1923
+ await reply(t('该审批已处理或不存在,无需重复操作。')).catch(() => undefined);
1924
+ }
1925
+ return;
1926
+ }
1927
+ // Question option buttons: answer:<interactionId>:<index>:<optionLabel>
1928
+ if (action.startsWith('answer:')) {
1929
+ const rest = action.slice('answer:'.length);
1930
+ const firstSep = rest.indexOf(':');
1931
+ if (firstSep !== -1) {
1932
+ const interactionId = rest.slice(0, firstSep);
1933
+ const afterId = rest.slice(firstSep + 1);
1934
+ const indexSep = afterId.indexOf(':');
1935
+ const indexText = indexSep === -1 ? afterId : afterId.slice(0, indexSep);
1936
+ const optionLabel = indexSep === -1 ? '' : afterId.slice(indexSep + 1);
1937
+ const qKey = this.#interactionKeys.get(interactionId);
1938
+ const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
1939
+ // Only the actor who started the interaction may answer it, and the
1940
+ // card must still target the current question (a stale card from an
1941
+ // earlier question in a multi-question interaction must not submit).
1942
+ if (pending && pending.kind === 'question' && !pending.submitting
1943
+ && pending.actor === actor
1944
+ && Number(indexText) === pending.index) {
1945
+ await this.#submitQuestionAnswer(pending, optionLabel, { chatId });
1946
+ } else {
1947
+ await reply(INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
1948
+ }
1949
+ }
1950
+ return;
1951
+ }
1875
1952
  if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
1876
1953
  const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
1877
1954
  await this.#showSessions(
@@ -3585,7 +3662,18 @@ export class FeishuHarnessBridge {
3585
3662
  const question = pending.questions[pending.index];
3586
3663
  if (!question) return;
3587
3664
 
3588
- pending.answers.push(harnessAnswerForQuestion(question, text));
3665
+ await this.#submitQuestionAnswer(pending, text, {
3666
+ chatId: event.message.chat_id,
3667
+ messageId,
3668
+ });
3669
+ }
3670
+
3671
+ async #submitQuestionAnswer(pending, answerText, { chatId, messageId } = {}) {
3672
+ const question = pending.questions[pending.index];
3673
+ if (!question) return;
3674
+ pending.chatId = chatId ?? pending.chatId;
3675
+
3676
+ pending.answers.push(harnessAnswerForQuestion(question, answerText));
3589
3677
  pending.index += 1;
3590
3678
  if (pending.index < pending.questions.length) {
3591
3679
  if (pending.claimedReplyMessageId === messageId) {
@@ -3603,6 +3691,7 @@ export class FeishuHarnessBridge {
3603
3691
  }
3604
3692
 
3605
3693
  pending.submitting = true;
3694
+ const key = pending.key;
3606
3695
  try {
3607
3696
  await pending.interaction.respond({
3608
3697
  ok: true,
@@ -3620,7 +3709,9 @@ export class FeishuHarnessBridge {
3620
3709
  if (error?.code === 'interaction-not-pending') {
3621
3710
  this.#rememberResolvedInteraction(key, pending);
3622
3711
  this.#clearPendingInteraction(key, pending.interactionId);
3623
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3712
+ if (chatId && messageId) {
3713
+ await this.#send(chatId, INTERACTION_RESOLVED_TEXT(), { replyTo: messageId }).catch(() => undefined);
3714
+ }
3624
3715
  return;
3625
3716
  }
3626
3717
  pending.submitting = false;
@@ -3628,8 +3719,10 @@ export class FeishuHarnessBridge {
3628
3719
  pending.index -= 1;
3629
3720
  this.#status.lastError = '回答提交失败。';
3630
3721
  this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
3631
- await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
3632
- .catch(() => undefined);
3722
+ if (chatId) {
3723
+ await this.#send(chatId, t('回答提交失败,请重新发送当前问题的答案。'))
3724
+ .catch(() => undefined);
3725
+ }
3633
3726
  }
3634
3727
  }
3635
3728
 
@@ -3645,6 +3738,32 @@ export class FeishuHarnessBridge {
3645
3738
  actor,
3646
3739
  requiresMention,
3647
3740
  send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
3741
+ // Approvals render as interactive cards with approve/reject buttons by
3742
+ // default. Set the bridge `interactionCards` option (or
3743
+ // DSH_IM_INTERACTION_CARDS=0) to keep the plain-text reply flow.
3744
+ ...(this.#interactionCards
3745
+ ? {
3746
+ render: async (pending) => {
3747
+ // Show the approval as an interactive card with approve/reject buttons.
3748
+ await this.#sendCard(
3749
+ chatId,
3750
+ approvalCard({
3751
+ toolName: pending.toolCall?.name ?? pending.payload?.toolName,
3752
+ operation: operationArguments(pending.toolCall),
3753
+ reason: pending.payload?.reason,
3754
+ approvalId: pending.approvalId,
3755
+ }),
3756
+ { key, replyTo: replyToMessageId },
3757
+ ).catch(async () => {
3758
+ // Fall back to the plain-text approval if the card cannot be
3759
+ // sent. If the text send also fails, let the error propagate so
3760
+ // the pending approval is not marked as presented and the
3761
+ // existing retry/reconnect logic can run.
3762
+ await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
3763
+ });
3764
+ },
3765
+ }
3766
+ : {}),
3648
3767
  })) return;
3649
3768
 
3650
3769
  // Approval requests return above; the existing question state machine stays unchanged.
@@ -3738,18 +3857,53 @@ export class FeishuHarnessBridge {
3738
3857
  async #presentInteraction(pending) {
3739
3858
  const question = pending.questions[pending.index];
3740
3859
  if (!question) return;
3741
- const messageId = await this.#send(
3742
- pending.chatId,
3743
- harnessQuestionText(
3744
- question,
3745
- pending.index,
3746
- pending.questions.length,
3747
- { requiresMention: pending.requiresMention },
3748
- ),
3749
- // Reply to the message that started the turn so the question lands in
3750
- // the same Feishu thread/topic instead of the group's default area.
3751
- { replyTo: pending.replyToMessageId },
3752
- );
3860
+ const options = Array.isArray(question?.options) ? question.options : [];
3861
+ // Single-choice questions with options render as interactive cards by
3862
+ // default. Multi-select or free-text questions and the text reply flow
3863
+ // remain when `interactionCards` is disabled (or DSH_IM_INTERACTION_CARDS=0).
3864
+ const interactive = this.#interactionCards
3865
+ && options.length > 0
3866
+ && question.multiSelect !== true;
3867
+ let messageId;
3868
+ if (interactive) {
3869
+ // Single-choice question with options: render each option as a button.
3870
+ messageId = await this.#sendCard(
3871
+ pending.chatId,
3872
+ questionCard({
3873
+ interactionId: pending.interactionId,
3874
+ header: question.header,
3875
+ question: question.question,
3876
+ detail: question.detail,
3877
+ options,
3878
+ index: pending.index,
3879
+ total: pending.questions.length,
3880
+ }),
3881
+ { key: pending.key, replyTo: pending.replyToMessageId },
3882
+ ).catch(async () => {
3883
+ // Fall back to the plain-text question if the card cannot be sent.
3884
+ // If the text send also fails, let the error propagate so the pending
3885
+ // question is not marked as presented and the existing retry logic runs.
3886
+ return this.#send(
3887
+ pending.chatId,
3888
+ harnessQuestionText(question, pending.index, pending.questions.length, {
3889
+ requiresMention: pending.requiresMention,
3890
+ }),
3891
+ { replyTo: pending.replyToMessageId },
3892
+ );
3893
+ });
3894
+ } else {
3895
+ // Multi-select or free-text questions keep the plain-text reply flow.
3896
+ messageId = await this.#send(
3897
+ pending.chatId,
3898
+ harnessQuestionText(
3899
+ question,
3900
+ pending.index,
3901
+ pending.questions.length,
3902
+ { requiresMention: pending.requiresMention },
3903
+ ),
3904
+ { replyTo: pending.replyToMessageId },
3905
+ );
3906
+ }
3753
3907
  if (messageId) {
3754
3908
  pending.questionMessageIds.add(messageId);
3755
3909
  if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
@@ -492,6 +492,7 @@ export function menuHelpText() {
492
492
  '/session ID 绑定已有会话',
493
493
  '/workspacelist 列出工作区',
494
494
  '/workspace 工作区序号或绝对路径 切换工作区',
495
+ '/ws、/wsl、/workspaces 工作区命令别名',
495
496
  '/new 开启全新会话',
496
497
  '',
497
498
  '📊 状态 / 压缩',
@@ -595,6 +596,7 @@ export function helpCard(extraTextLines = []) {
595
596
  { tag: 'hr' },
596
597
  { tag: 'div', text: markdown([
597
598
  t(HELP_TEXT_COMMANDS),
599
+ t('/ws、/wsl、/workspaces 工作区命令别名'),
598
600
  t('`/version` — 查看插件版本'),
599
601
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
600
602
  ].join('\n') + extraText) },
@@ -867,3 +869,70 @@ export function customSteerCard() {
867
869
  ];
868
870
  return cardWith(t('➕ 自定义指令'), elements);
869
871
  }
872
+
873
+ /**
874
+ * Interactive approval card with approve / reject buttons. Action values carry
875
+ * the approvalId so the card callback can submit the decision:
876
+ * approve:<approvalId> / reject:<approvalId>
877
+ * `requiresMention` is advisory; a button click is itself the operator's
878
+ * explicit intent, so it does not need an @ mention in groups.
879
+ */
880
+ export function approvalCard({ toolName, operation, reason, approvalId }) {
881
+ const elements = [];
882
+ if (toolName) {
883
+ elements.push({ tag: 'div', text: markdown(t('工具:{tool}', { tool: String(toolName) })) });
884
+ }
885
+ if (operation) {
886
+ // Cap the operation text so an oversized argument list cannot overflow the
887
+ // card (the plain-text path rejects >6000 chars; here we truncate so the
888
+ // approve/reject buttons still render).
889
+ const MAX_OPERATION_CHARS = 6_000;
890
+ const op = String(operation);
891
+ const shown = op.length > MAX_OPERATION_CHARS
892
+ ? `${op.slice(0, MAX_OPERATION_CHARS)}\n…(操作参数过长,已截断)`
893
+ : op;
894
+ elements.push({ tag: 'div', text: markdown(t('操作参数:\n{operation}', { operation: shown })) });
895
+ }
896
+ if (reason) {
897
+ elements.push({ tag: 'div', text: markdown(t('原因:{reason}', { reason: String(reason) })) });
898
+ }
899
+ elements.push(
900
+ { tag: 'hr' },
901
+ buttonPair(t('✅ 批准'), `approve:${approvalId}`, t('❌ 拒绝'), `reject:${approvalId}`),
902
+ );
903
+ return cardWith(t('🔐 工具审批'), elements);
904
+ }
905
+
906
+ /**
907
+ * Interactive question card. When the question carries options, each option is
908
+ * rendered as its own button; the selected option label is submitted via a
909
+ * card callback. Multi-select questions fall back to the plain-text flow (the
910
+ * caller decides), because a multi-select needs a confirm step.
911
+ * Action: answer:<interactionId>:<optionLabel>
912
+ */
913
+ export function questionCard({ interactionId, header, question, detail, options, index, total }) {
914
+ const elements = [];
915
+ const progress = total > 1 ? `(${index + 1}/${total})` : '';
916
+ if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
917
+ const qText = typeof question === 'string' && question.trim() ? question : t('请输入你的回答。');
918
+ elements.push({ tag: 'div', text: markdown(String(qText)) });
919
+ if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
920
+
921
+ if (Array.isArray(options) && options.length > 0) {
922
+ elements.push({ tag: 'hr' });
923
+ for (const option of options) {
924
+ const label = typeof option?.label === 'string' ? option.label : '';
925
+ if (!label) continue;
926
+ const description = typeof option?.description === 'string' && option.description.trim()
927
+ ? option.description.trim()
928
+ : '';
929
+ // Include the option description in the button so the user sees the full
930
+ // meaning (mirrors the text form "1. label — description").
931
+ const buttonText = description ? `${label}\n${description}` : label;
932
+ // Action carries the question index so a stale card from a previous
933
+ // question cannot be applied to the current one: answer:<interactionId>:<index>:<label>
934
+ elements.push(button(buttonText, `answer:${interactionId}:${index}:${label}`));
935
+ }
936
+ }
937
+ return cardWith(t('❓ 请补充信息{progress}', { progress }), elements);
938
+ }
@@ -288,6 +288,11 @@ export class FeishuRuntime {
288
288
  groupResponseMode: this.#groupResponseMode,
289
289
  repair: this.#repair,
290
290
  replyTimeoutMs: this.#replyTimeoutMs,
291
+ // Interaction cards (approval/question buttons) are on by default.
292
+ // Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
293
+ interactionCards: !['0', 'false', 'no', 'off'].includes(
294
+ String(process.env.DSH_IM_INTERACTION_CARDS ?? '').trim().toLowerCase(),
295
+ ),
291
296
  signal,
292
297
  logger: this.#logger,
293
298
  });
@@ -43,6 +43,9 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
43
43
  { command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
44
44
  { command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
45
45
  { command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
46
+ { command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
47
+ { command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
48
+ { command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
46
49
  { command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
47
50
  { command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
48
51
  { command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
@@ -90,6 +90,7 @@ function helpText() {
90
90
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
91
91
  t('/workspace 工作区序号或绝对路径 切换工作区'),
92
92
  t('/workspacelist 列出工作区绝对路径'),
93
+ t('/ws、/wsl、/workspaces 工作区命令别名'),
93
94
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
94
95
  t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
95
96
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),