@xmanrui/dsh-im 2.2.1 → 2.4.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 (31) hide show
  1. package/README.en.md +12 -3
  2. package/README.md +12 -3
  3. package/lib/client.js +95 -52
  4. package/lib/index.js +220 -191
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/feishu/index.js +37 -27
  7. package/plugin-src/client/channels/feishu/styles.js +26 -1
  8. package/plugin-src/client/i18n.js +27 -25
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +96 -3
  10. package/src/channels/discord/discord-runtime.mjs +3 -0
  11. package/src/channels/feishu/bridge.mjs +176 -31
  12. package/src/channels/feishu/feishu-cards.mjs +18 -6
  13. package/src/channels/feishu/feishu-runtime.mjs +0 -1
  14. package/src/channels/feishu/message-utils.mjs +1 -1
  15. package/src/channels/feishu/multi-bot-controller.mjs +1 -17
  16. package/src/channels/feishu/repair-manager.mjs +7 -2
  17. package/src/channels/qq/qq-bridge.mjs +99 -8
  18. package/src/channels/shared/batch-input.mjs +209 -0
  19. package/src/channels/shared/control-command.mjs +11 -1
  20. package/src/channels/shared/i18n-en/feishu.mjs +28 -25
  21. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  22. package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
  23. package/src/channels/shared/i18n-en/shared-c.mjs +49 -0
  24. package/src/channels/shared/i18n-en/telegram.mjs +1 -0
  25. package/src/channels/shared/text-harness-bridge.mjs +112 -2
  26. package/src/channels/slack/slack-runtime.mjs +1 -0
  27. package/src/channels/telegram/telegram-runtime.mjs +5 -0
  28. package/src/channels/wecom/wecom-bridge.mjs +102 -4
  29. package/src/channels/weixin/weixin-bridge.mjs +101 -3
  30. package/src/channels/weixin/weixin-runtime.mjs +18 -1
  31. package/src/channels/whatsapp/whatsapp-runtime.mjs +2 -0
@@ -22,6 +22,12 @@ import {
22
22
  validHarnessQuestion,
23
23
  } from '../shared/harness-question.mjs';
24
24
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
25
+ import {
26
+ BatchInputManager,
27
+ batchInputBusyMessage,
28
+ batchInputGroupUnsupportedMessage,
29
+ isBatchInputCommand,
30
+ } from '../shared/batch-input.mjs';
25
31
  import { runCompactCommand } from '../shared/compact-command.mjs';
26
32
  import {
27
33
  isControlCommand,
@@ -100,9 +106,10 @@ const CARD_OVERLOAD_NOTICE_COOLDOWN_MS = 5_000;
100
106
  const CARD_DATA_TIMEOUT_MS = 5_000;
101
107
  const REPAIR_LINK_WAIT_MS = 15_000;
102
108
  const REPAIR_POLL_INTERVAL_MS = 1_000;
103
- const REPAIR_ACTIVE_STATES = new Set([
104
- 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', 'saving',
109
+ const REPAIR_AUTHORIZATION_STATES = new Set([
110
+ 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
105
111
  ]);
112
+ const REPAIR_ACTIVE_STATES = new Set([...REPAIR_AUTHORIZATION_STATES, 'saving']);
106
113
  const REPAIR_TERMINAL_STATES = new Set([
107
114
  'succeeded', 'expired', 'cancelled', 'error',
108
115
  ]);
@@ -144,7 +151,7 @@ function artifactFailureText(fileName, error) {
144
151
  const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
145
152
  switch (error?.code) {
146
153
  case 'artifact-permission-required':
147
- return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。', { name });
154
+ return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
148
155
  case 'artifact-too-large':
149
156
  return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
150
157
  case 'artifact-empty':
@@ -366,6 +373,7 @@ export class FeishuHarnessBridge {
366
373
  #harness;
367
374
  #state;
368
375
  #queues = new Map();
376
+ #batchInputs = new BatchInputManager();
369
377
  #pendingInteractions = new Map();
370
378
  #interactionKeys = new Map();
371
379
  #resolvedQuestionReplies = new Map();
@@ -403,7 +411,6 @@ export class FeishuHarnessBridge {
403
411
  #botOpenId;
404
412
  #groupResponseMode;
405
413
  #repair;
406
- #repairOwnerOpenIds;
407
414
  #repairAttempt = null;
408
415
  #repairMonitorVersion = 0;
409
416
  #repairPollIntervalMs;
@@ -436,7 +443,6 @@ export class FeishuHarnessBridge {
436
443
  botOpenId,
437
444
  groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
438
445
  repair,
439
- repairOwnerOpenIds,
440
446
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
441
447
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
442
448
  cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
@@ -473,10 +479,6 @@ export class FeishuHarnessBridge {
473
479
  this.#botOpenId = nonEmptyString(botOpenId);
474
480
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
475
481
  this.#repair = repair ?? null;
476
- const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
477
- this.#repairOwnerOpenIds = new Set(
478
- [...(repairOwners ?? [])].filter((value) => typeof value === 'string' && value && value !== '*'),
479
- );
480
482
  this.#repairPollIntervalMs = repairPollIntervalMs;
481
483
  this.#repairLinkWaitMs = repairLinkWaitMs;
482
484
  this.#cardDataTimeoutMs = cardDataTimeoutMs;
@@ -542,6 +544,58 @@ export class FeishuHarnessBridge {
542
544
  const processingReaction = this.#addReaction(messageId, 'OnIt');
543
545
  const commandMessage = extractInboundMessage(event, this.#client);
544
546
  const commandText = nonEmptyString(commandMessage.content) ?? '';
547
+ const batchText = event.message.message_type === 'text'
548
+ ? nonEmptyString(extractText(event)) ?? ''
549
+ : '';
550
+ const batchCommand = event.message.message_type === 'text'
551
+ && isBatchInputCommand(batchText);
552
+ const pending = this.#pendingInteractions.get(key);
553
+ const batchStatus = this.#batchInputs.status(key);
554
+ if (batchCommand && event.message.chat_type !== 'p2p') {
555
+ return this.#finishBatchResult(
556
+ event,
557
+ messageId,
558
+ processingReaction,
559
+ { message: batchInputGroupUnsupportedMessage() },
560
+ );
561
+ }
562
+ if (event.message.chat_type === 'p2p'
563
+ && (batchCommand || batchStatus.phase === 'collecting')) {
564
+ const exactBatchStart = /^\/batch$/iu.test(batchText);
565
+ const result = exactBatchStart
566
+ && batchStatus.phase === 'idle'
567
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
568
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
569
+ : this.#batchInputs.handle(key, batchText, {
570
+ plainText: event.message.message_type === 'text' && Boolean(batchText),
571
+ });
572
+ if (result.handled) {
573
+ if (result.kind === 'submit') {
574
+ const submissionEvent = {
575
+ ...event,
576
+ batchSubmission: { token: result.token },
577
+ message: {
578
+ ...event.message,
579
+ message_type: 'text',
580
+ content: JSON.stringify({ text: result.prompt }),
581
+ mentions: [],
582
+ },
583
+ };
584
+ return this.#enqueueMessage(
585
+ submissionEvent,
586
+ messageId,
587
+ key,
588
+ processingReaction,
589
+ );
590
+ }
591
+ return this.#finishBatchResult(
592
+ event,
593
+ messageId,
594
+ processingReaction,
595
+ result,
596
+ );
597
+ }
598
+ }
545
599
  // Card commands (/m, /help, /status, etc.) bypass the queue so they
546
600
  // respond immediately even when a harness task is still streaming.
547
601
  if (CARD_COMMAND.test(commandText)) {
@@ -599,7 +653,6 @@ export class FeishuHarnessBridge {
599
653
  .finally(() => this.#acceptedMessageIds.delete(messageId));
600
654
  return current;
601
655
  }
602
- const pending = this.#pendingInteractions.get(key);
603
656
  const approvalReply = this.#approvals.claimReply({
604
657
  key,
605
658
  actor: senderOpenId(event),
@@ -691,6 +744,32 @@ export class FeishuHarnessBridge {
691
744
  return this.#enqueueMessage(event, messageId, key, processingReaction);
692
745
  }
693
746
 
747
+ #finishBatchResult(event, messageId, processingReaction, result) {
748
+ let current;
749
+ current = Promise.resolve()
750
+ .then(async () => {
751
+ if (this.#state.hasSeen(messageId)) return;
752
+ await this.#state.markSeen(messageId);
753
+ this.#status.lastMessageAt = new Date().toISOString();
754
+ this.#status.messagesReceived += 1;
755
+ if (result?.message) await this.#send(event.message.chat_id, result.message);
756
+ this.#status.lastError = null;
757
+ })
758
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
759
+ .catch((error) => this.#handleMessageFailure(
760
+ event,
761
+ messageId,
762
+ processingReaction,
763
+ error,
764
+ ))
765
+ .finally(() => {
766
+ this.#acceptedMessageIds.delete(messageId);
767
+ this.#commandTasks.delete(current);
768
+ });
769
+ this.#commandTasks.add(current);
770
+ return current;
771
+ }
772
+
694
773
  #enqueueMessage(event, messageId, key, processingReaction, {
695
774
  releaseMessageId = true,
696
775
  alreadyRecorded = false,
@@ -725,6 +804,9 @@ export class FeishuHarnessBridge {
725
804
  async #handleMessageFailure(event, messageId, processingReaction, error) {
726
805
  if (error?.code === 'turn-stopped') {
727
806
  await this.#removeProcessingReaction(messageId, processingReaction);
807
+ if (error?.batchInputMessage) {
808
+ await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
809
+ }
728
810
  return;
729
811
  }
730
812
  if (this.#signal?.aborted) {
@@ -736,7 +818,8 @@ export class FeishuHarnessBridge {
736
818
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
737
819
  await this.#send(
738
820
  event.message.chat_id,
739
- inboundFileUserMessage(error)
821
+ error?.batchInputMessage
822
+ ?? inboundFileUserMessage(error)
740
823
  ?? imagePromptUserMessage(error)
741
824
  ?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
742
825
  ).catch(() => undefined);
@@ -939,12 +1022,38 @@ export class FeishuHarnessBridge {
939
1022
  }
940
1023
 
941
1024
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
1025
+ const batchSubmission = event.batchSubmission ?? null;
1026
+ let batchAskCompleted = false;
942
1027
  try {
943
- const receipt = await this.#answerWithStream(event, key, message);
1028
+ const receipt = await this.#answerWithStream(event, key, message, {
1029
+ onAskComplete: batchSubmission
1030
+ ? () => {
1031
+ batchAskCompleted = this.#batchInputs.complete(
1032
+ key,
1033
+ batchSubmission.token,
1034
+ ).completed;
1035
+ }
1036
+ : undefined,
1037
+ });
944
1038
  this.#status.messagesReplied += 1;
945
1039
  this.#status.lastReplyAt = new Date().toISOString();
946
1040
  this.#status.lastError = null;
947
1041
  return receipt;
1042
+ } catch (error) {
1043
+ if (batchSubmission && !batchAskCompleted) {
1044
+ if (error?.code === 'turn-stopped') {
1045
+ this.#batchInputs.complete(key, batchSubmission.token);
1046
+ throw error;
1047
+ }
1048
+ const failed = this.#batchInputs.fail(key, batchSubmission.token);
1049
+ if (failed.retained) {
1050
+ const batchError = new Error(error?.message ?? String(error), { cause: error });
1051
+ batchError.code = error?.code;
1052
+ batchError.batchInputMessage = failed.message;
1053
+ throw batchError;
1054
+ }
1055
+ }
1056
+ throw error;
948
1057
  } finally {
949
1058
  await this.#cancelPendingInteraction(key);
950
1059
  await this.#approvals.closeRoute(key);
@@ -961,13 +1070,8 @@ export class FeishuHarnessBridge {
961
1070
  return;
962
1071
  }
963
1072
  const actorOpenId = strictSenderOpenId(event);
964
- if (!actorOpenId || !this.#repairOwnerOpenIds.has(actorOpenId)) {
965
- await this.#send(
966
- event.message.chat_id,
967
- this.#repairOwnerOpenIds.size === 0
968
- ? t('当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。')
969
- : t('此操作只能由机器人接入者在私聊中发起,未进行任何修改。'),
970
- );
1073
+ if (!actorOpenId) {
1074
+ await this.#send(event.message.chat_id, t('无法识别当前发送者,未发起修复。'));
971
1075
  return;
972
1076
  }
973
1077
  if (!this.#repair) {
@@ -996,7 +1100,7 @@ export class FeishuHarnessBridge {
996
1100
  return;
997
1101
  }
998
1102
  if (attempt.actorOpenId !== actorOpenId) {
999
- await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
1103
+ await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
1000
1104
  return;
1001
1105
  }
1002
1106
  if (operation === 'cancel') {
@@ -1048,21 +1152,53 @@ export class FeishuHarnessBridge {
1048
1152
 
1049
1153
  async #startRepair({ actorOpenId, chatId }) {
1050
1154
  const previous = this.#repairAttempt;
1155
+ let restarted = false;
1051
1156
  if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
1052
1157
  if (previous.actorOpenId !== actorOpenId) {
1053
- await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
1158
+ await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
1054
1159
  return;
1055
1160
  }
1161
+ let current;
1056
1162
  try {
1057
- const current = await this.#refreshRepairAttempt(previous);
1058
- if (REPAIR_ACTIVE_STATES.has(current.state) && previous.verificationUrl) {
1059
- await this.#sendRepairLink(chatId, previous.verificationUrl, current, { existing: true });
1060
- return;
1061
- }
1163
+ current = await this.#refreshRepairAttempt(previous);
1062
1164
  } catch {
1063
1165
  await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
1064
1166
  return;
1065
1167
  }
1168
+ // Once Feishu has accepted the update, starting another attempt could
1169
+ // race the credential swap and callback probe. Status commands remain
1170
+ // available while that non-cancellable convergence is in progress.
1171
+ if (current.state === 'saving') {
1172
+ await this.#send(chatId, this.#repairStatusText(current));
1173
+ return;
1174
+ }
1175
+ // Feishu launcher links carry one-time user codes. Opening one with the
1176
+ // wrong Open Platform account can consume it even though authorization
1177
+ // did not succeed, so a fresh bare /repair must replace a still-waiting
1178
+ // attempt instead of redisplaying the same unusable URL.
1179
+ if (REPAIR_AUTHORIZATION_STATES.has(current.state)) {
1180
+ let cancelled;
1181
+ try {
1182
+ cancelled = repairSnapshot(
1183
+ await this.#repair.cancel(this.#repairArgs(previous)),
1184
+ { botId: this.#botId },
1185
+ );
1186
+ previous.snapshot = cancelled;
1187
+ } catch {
1188
+ await this.#send(chatId, t('暂时无法取消旧修复任务,未生成新链接;请稍后重试。'));
1189
+ return;
1190
+ }
1191
+ if (!REPAIR_TERMINAL_STATES.has(cancelled.state)) {
1192
+ await this.#send(chatId, this.#repairStatusText(cancelled));
1193
+ return;
1194
+ }
1195
+ previous.stopped = true;
1196
+ this.#repairMonitorVersion += 1;
1197
+ restarted = cancelled.state === 'cancelled';
1198
+ } else if (REPAIR_TERMINAL_STATES.has(current.state)) {
1199
+ previous.stopped = true;
1200
+ this.#repairMonitorVersion += 1;
1201
+ }
1066
1202
  }
1067
1203
 
1068
1204
  let snapshot;
@@ -1111,7 +1247,7 @@ export class FeishuHarnessBridge {
1111
1247
  await this.#send(chatId, t('飞书未返回授权链接,已中止本次修复。'));
1112
1248
  return;
1113
1249
  }
1114
- await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
1250
+ await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot, { restarted });
1115
1251
  this.#monitorRepair(attempt);
1116
1252
  }
1117
1253
 
@@ -1189,15 +1325,15 @@ export class FeishuHarnessBridge {
1189
1325
  });
1190
1326
  }
1191
1327
 
1192
- async #sendRepairLink(chatId, url, snapshot, { existing = false } = {}) {
1328
+ async #sendRepairLink(chatId, url, snapshot, { restarted = false } = {}) {
1193
1329
  const remaining = snapshot.remainingSeconds
1194
1330
  ?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
1195
1331
  const expiry = remaining === null
1196
1332
  ? t('链接为短期有效')
1197
1333
  : t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
1198
1334
  await this.#send(chatId, [
1199
- existing ? t('已有一个修复任务在等待授权。') : t('🔧 准备修复卡片按钮。'),
1200
- t('本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。'),
1335
+ restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
1336
+ t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
1201
1337
  '',
1202
1338
  t('当前设备直接打开:'),
1203
1339
  url,
@@ -2937,10 +3073,16 @@ export class FeishuHarnessBridge {
2937
3073
  };
2938
3074
  }
2939
3075
 
2940
- async #answerWithStream(event, key, message) {
3076
+ async #answerWithStream(event, key, message, { onAskComplete } = {}) {
2941
3077
  const chatId = event.message.chat_id;
2942
3078
  const messageId = event.message.message_id;
2943
3079
  const text = message.content;
3080
+ let askCompleted = false;
3081
+ const markAskComplete = () => {
3082
+ if (askCompleted) return;
3083
+ askCompleted = true;
3084
+ onAskComplete?.();
3085
+ };
2944
3086
  const content = hasInboundImages(message)
2945
3087
  ? await promptContentForMessage(message, { signal: this.#signal })
2946
3088
  : undefined;
@@ -2955,6 +3097,7 @@ export class FeishuHarnessBridge {
2955
3097
  existsOptions: { signal: this.#signal },
2956
3098
  askOptions: this.#interactionAskOptions(event, key, message.files),
2957
3099
  });
3100
+ markAskComplete();
2958
3101
  let textReceipt;
2959
3102
  let textSendError = null;
2960
3103
  try {
@@ -3009,6 +3152,7 @@ export class FeishuHarnessBridge {
3009
3152
  existsOptions: { signal: this.#signal },
3010
3153
  askOptions,
3011
3154
  });
3155
+ markAskComplete();
3012
3156
  completedAnswer = completed.answer;
3013
3157
  completedArtifacts = completed.artifacts ?? [];
3014
3158
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
@@ -3067,6 +3211,7 @@ export class FeishuHarnessBridge {
3067
3211
  existsOptions: { signal: this.#signal },
3068
3212
  askOptions: this.#interactionAskOptions(event, key, message.files),
3069
3213
  });
3214
+ markAskComplete();
3070
3215
  let textReceipt;
3071
3216
  let textSendError = null;
3072
3217
  try {
@@ -124,7 +124,7 @@ function backButton() {
124
124
  * - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
125
125
  * - modelCatalog: object|null (模型目录, {groups,current})
126
126
  * Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
127
- * 5=修复 6=帮助.
127
+ * 5=补全权限 6=帮助.
128
128
  */
129
129
  export function menuCard(ctx) {
130
130
  const {
@@ -323,7 +323,7 @@ export function menuCard(ctx) {
323
323
 
324
324
  // 命令与数字兜底说明
325
325
  elements.push({ tag: 'div', text: markdown(t(
326
- '**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助',
326
+ '**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧补全权限 · **6**帮助',
327
327
  )) });
328
328
  return cardWith(t('🤖 助手中心'), elements);
329
329
  }
@@ -495,6 +495,7 @@ export function menuHelpText() {
495
495
  '',
496
496
  '📊 状态 / 压缩',
497
497
  '/status 连接状态',
498
+ '/version 查看插件版本',
498
499
  '/compact 压缩当前会话上下文',
499
500
  '/archived on/off 会话列表显示/隐藏归档',
500
501
  '',
@@ -513,10 +514,15 @@ export function menuHelpText() {
513
514
  '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
514
515
  '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
515
516
  '',
517
+ '📦 批量输入(仅私聊)',
518
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)',
519
+ '/send 提交当前批次',
520
+ '/cancel 取消当前批次',
521
+ '',
516
522
  '🎮 任务控制',
517
523
  '/stop 停止当前任务',
518
524
  '/steer 指令 给 Agent 补充指令',
519
- '/repair 修复卡片按钮回调',
525
+ '/repair 补全飞书权限与卡片回调',
520
526
  ].map((line) => t(line)).join('\n');
521
527
  }
522
528
 
@@ -564,14 +570,17 @@ const HELP_TEXT_COMMANDS = [
564
570
  '`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
565
571
  '`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
566
572
  '`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
567
- '`/repair` — 修复卡片按钮',
573
+ '`/batch` — 开启批量输入(仅私聊,最多 10 条文字)',
574
+ '`/send` — 提交当前批次',
575
+ '`/cancel` — 取消当前批次',
576
+ '`/repair` — 补全飞书权限与卡片回调',
568
577
  ].join('\n');
569
578
 
570
579
  const HELP_NUMBER_FALLBACK = [
571
580
  '**💡 数字兜底**',
572
581
  '回复数字快速操作:',
573
582
  '**1**工作区列表 · **2**新会话 · **3**会话/关注',
574
- '**4**状态 · **5**修复 · **6**帮助',
583
+ '**4**状态 · **5**补全权限 · **6**帮助',
575
584
  ].join('\n');
576
585
 
577
586
  export function helpCard(extraTextLines = []) {
@@ -581,7 +590,10 @@ export function helpCard(extraTextLines = []) {
581
590
  const elements = [
582
591
  { tag: 'div', text: markdown(t(HELP_CARD_FEATURES)) },
583
592
  { tag: 'hr' },
584
- { tag: 'div', text: markdown(t(HELP_TEXT_COMMANDS) + extraText) },
593
+ { tag: 'div', text: markdown([
594
+ t(HELP_TEXT_COMMANDS),
595
+ t('`/version` — 查看插件版本'),
596
+ ].join('\n') + extraText) },
585
597
  { tag: 'hr' },
586
598
  { tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
587
599
  { tag: 'hr' },
@@ -267,7 +267,6 @@ export class FeishuRuntime {
267
267
  botOpenId: this.#botOpenId,
268
268
  groupResponseMode: this.#groupResponseMode,
269
269
  repair: this.#repair,
270
- repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
271
270
  replyTimeoutMs: this.#replyTimeoutMs,
272
271
  signal,
273
272
  logger: this.#logger,
@@ -5,7 +5,7 @@ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
5
5
  const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
6
6
  const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
7
7
  const FEISHU_IMAGE_PERMISSION_MESSAGE =
8
- '飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。';
8
+ '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
9
9
 
10
10
  export function conversationKey(event) {
11
11
  const chatType = event?.message?.chat_type;
@@ -869,22 +869,6 @@ export class MultiBotDshFeishuController {
869
869
  'Feishu returned credentials for a different account domain.',
870
870
  );
871
871
  }
872
- if (record.initiator.actorOpenId && record.initiator.actorOpenId !== ownerOpenId) {
873
- throw this.#callbackRepairError(
874
- record,
875
- 'repair_owner_mismatch',
876
- 'The Feishu repair was confirmed by a different operator.',
877
- );
878
- }
879
- if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
880
- && !target.ownerOpenIds.includes(ownerOpenId)) {
881
- throw this.#callbackRepairError(
882
- record,
883
- 'repair_owner_mismatch',
884
- 'The Feishu repair operator is not an owner of this configured bot.',
885
- );
886
- }
887
-
888
872
  record.stage = 'verifying_identity';
889
873
  let verified;
890
874
  try {
@@ -1008,7 +992,7 @@ export class MultiBotDshFeishuController {
1008
992
  record.stage = 'awaiting_callback';
1009
993
  try {
1010
994
  const proof = await runtime.beginCardActionProbe({
1011
- expectedOperatorOpenId: ownerOpenId,
995
+ expectedOperatorOpenId: record.initiator.actorOpenId ?? ownerOpenId,
1012
996
  timeoutMs: this.#callbackProbeTimeoutMs,
1013
997
  ...(record.initiator.chatId ? { chatId: record.initiator.chatId } : {}),
1014
998
  });
@@ -1,6 +1,8 @@
1
1
  import { RegistrationManager } from './registration-manager.mjs';
2
2
 
3
3
  export const CARD_ACTION_CALLBACK = 'card.action.trigger';
4
+ export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
5
+ export const FEISHU_RESOURCE_SCOPE = 'im:resource';
4
6
  export const CALLBACK_REPAIR_OPERATION = 'callback_repair';
5
7
 
6
8
  function accountsDomain(domain) {
@@ -71,8 +73,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
71
73
  /**
72
74
  * One targeted update attempt for an existing Feishu app. It intentionally
73
75
  * shares RegistrationManager's polling/state implementation while fixing the
74
- * update manifest in one place so callers cannot accidentally add scopes,
75
- * events, presets, or createOnly.
76
+ * update manifest in one place so callers can add only the card callback, the
77
+ * message-read scope needed to download user-sent media, and the resource
78
+ * scope needed to upload bot-sent images/files, without adding unrelated
79
+ * scopes, events, presets, or createOnly.
76
80
  */
77
81
  export class CallbackRepairManager {
78
82
  #manager;
@@ -106,6 +110,7 @@ export class CallbackRepairManager {
106
110
  appId: this.#appId,
107
111
  addons: {
108
112
  preset: false,
113
+ scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
109
114
  callbacks: { items: [CARD_ACTION_CALLBACK] },
110
115
  },
111
116
  });