@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
@@ -96,4 +96,53 @@ export default {
96
96
 
97
97
  // agent-preset.mjs
98
98
  'Agent Preset 无效。': 'Invalid Agent Preset.',
99
+
100
+ // batch-input.mjs
101
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)':
102
+ '/batch Start batch input (direct messages only, up to 10 text messages)',
103
+ '/send 提交当前批次': '/send Submit the current batch',
104
+ '/cancel 取消当前批次': '/cancel Cancel the current batch',
105
+ '开始批量输入(仅私聊)': 'Start batch input (direct messages only)',
106
+ '提交当前批次': 'Submit the current batch',
107
+ '取消当前批次': 'Cancel the current batch',
108
+ '当前已处于批量输入模式,已收集 {count}/{limit} 条。\n请发送 /send 提交或 /cancel 取消。':
109
+ 'Batch input is already active with {count}/{limit} messages collected.\nSend /send to submit or /cancel to cancel.',
110
+ '当前已处于批量输入模式,已收集 {count}/{limit} 条。\n完成后发送 /send,取消请发送 /cancel。':
111
+ 'Batch input is already active with {count}/{limit} messages collected.\nSend /send when finished or /cancel to cancel.',
112
+ '[消息 {index}]': '[Message {index}]',
113
+ '以下是用户通过批量输入模式发送的多条内容,请按顺序作为同一次输入统一处理。':
114
+ 'The user sent the following messages in batch input mode. Process them in order as one input.',
115
+ '批量输入模式仅支持私聊,请在与机器人的私聊中使用。':
116
+ 'Batch input is available only in direct messages. Please use it in a direct chat with the bot.',
117
+ '当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
118
+ 'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
119
+ '用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
120
+ '批量输入命令仅支持纯文字,请移除图片或文件后重试。':
121
+ 'Batch input commands support text only. Remove the image or file and try again.',
122
+ '当前没有待提交的批量内容,请先发送 /batch。':
123
+ 'There is no batch to submit. Send /batch first.',
124
+ '当前没有正在进行的批量输入。': 'There is no active batch input.',
125
+ '已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
126
+ 'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
127
+ '批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
128
+ 'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
129
+ '当前批次正在提交,请勿重复发送 /send。':
130
+ 'The current batch is being submitted. Do not send /send again.',
131
+ '批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
132
+ 'The batch has already been submitted and cannot be cancelled.\nSend /stop if you need to stop the current task.',
133
+ '当前批次正在提交,请等待处理完成后再开启新批次。':
134
+ 'The current batch is being submitted. Wait for it to finish before starting another batch.',
135
+ '已取消批量输入。': 'Batch input cancelled.',
136
+ '已取消批量输入,共丢弃 {count} 条消息。':
137
+ 'Batch input cancelled; {count} messages were discarded.',
138
+ '当前批次还没有内容,请先发送文字,或使用 /cancel 取消。':
139
+ 'The current batch is empty. Send some text first or use /cancel.',
140
+ '当前正在批量输入,请先发送 /send 提交或 /cancel 取消。':
141
+ 'Batch input is active. Send /send to submit or /cancel to cancel first.',
142
+ '当前批次已满,这条消息未收录。\n请先发送 /send 提交或 /cancel 取消,然后重新发送这条消息。':
143
+ 'The current batch is full, so this message was not collected.\nSend /send or /cancel first, then resend this message.',
144
+ '已收集 {count}/{limit} 条,当前批次已满,请发送 /send 提交或 /cancel 取消。':
145
+ 'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
146
+ '批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
147
+ 'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
99
148
  };
@@ -13,6 +13,7 @@ export default {
13
13
  '停止当前任务': 'Stop the current task',
14
14
  '纠偏当前任务': 'Steer the current task',
15
15
  '检查连接状态': 'Check connection status',
16
+ '查看插件版本': 'Show plugin version',
16
17
  '显示帮助': 'Show help',
17
18
  '该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。':
18
19
  'This Telegram bot already has a Webhook configured. Remove the Webhook from the original service and try again.',
@@ -19,6 +19,12 @@ import {
19
19
  } from './preset-command.mjs';
20
20
  import { askInWorkspaceSession } from './workspace-session.mjs';
21
21
  import { HarnessApprovalQueue } from './harness-approval.mjs';
22
+ import {
23
+ BatchInputManager,
24
+ batchInputBusyMessage,
25
+ batchInputGroupUnsupportedMessage,
26
+ isBatchInputCommand,
27
+ } from './batch-input.mjs';
22
28
  import {
23
29
  hasInboundImages,
24
30
  imagePromptUserMessage,
@@ -119,6 +125,7 @@ export class TextHarnessBridge {
119
125
  #approvalTasks = new Set();
120
126
  #commandTasks = new Set();
121
127
  #approvals;
128
+ #batches = new BatchInputManager();
122
129
 
123
130
  constructor({
124
131
  descriptor,
@@ -173,7 +180,54 @@ export class TextHarnessBridge {
173
180
  const key = `${kind}:${conversationId}`;
174
181
  const pending = this.#pendingInteractions.get(key);
175
182
  const text = cleanText(normalized.content);
176
- const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
183
+ const batchCommand = isBatchInputCommand(text);
184
+ if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
185
+ return this.#finishLocalMessage(
186
+ normalized,
187
+ messageId,
188
+ batchInputGroupUnsupportedMessage(),
189
+ );
190
+ }
191
+ if (batchCommand && normalized.kind === 'direct') {
192
+ const exactBatch = /^\/batch$/iu.test(text);
193
+ if (exactBatch && (
194
+ this.#queues.has(key)
195
+ || Boolean(pending)
196
+ || this.#approvals.hasPending(key)
197
+ )) {
198
+ return this.#finishLocalMessage(normalized, messageId, batchInputBusyMessage());
199
+ }
200
+ const batch = this.#batches.handle(key, text, {
201
+ plainText: Boolean(text)
202
+ && normalized.plainText !== false
203
+ && !hasInboundImages(normalized)
204
+ && !hasInboundFiles(normalized),
205
+ });
206
+ if (batch.handled) {
207
+ if (batch.kind === 'submit') {
208
+ return this.#enqueueMessage({
209
+ ...normalized,
210
+ content: batch.prompt,
211
+ batchSubmission: { token: batch.token },
212
+ }, messageId, senderId, key);
213
+ }
214
+ return this.#finishLocalMessage(normalized, messageId, batch.message);
215
+ }
216
+ } else if (normalized.kind === 'direct'
217
+ && this.#batches.status(key).phase === 'collecting') {
218
+ const batch = this.#batches.handle(key, text, {
219
+ plainText: Boolean(text)
220
+ && normalized.plainText !== false
221
+ && !hasInboundImages(normalized)
222
+ && !hasInboundFiles(normalized),
223
+ });
224
+ if (batch.handled) {
225
+ return this.#finishLocalMessage(normalized, messageId, batch.message);
226
+ }
227
+ }
228
+ const collectingBatch = normalized.kind === 'direct'
229
+ && this.#batches.status(key).phase === 'collecting';
230
+ const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
177
231
  ? runControlCommand
178
232
  : (isModelCommand(text)
179
233
  ? runModelCommand
@@ -254,6 +308,30 @@ export class TextHarnessBridge {
254
308
  return this.#enqueueMessage(normalized, messageId, senderId, key);
255
309
  }
256
310
 
311
+ #finishLocalMessage(message, messageId, reply) {
312
+ let task;
313
+ task = (async () => {
314
+ if (this.#state.hasSeen(messageId)) return;
315
+ await this.#state.markSeen(messageId);
316
+ this.#status.messagesReceived += 1;
317
+ this.#status.lastMessageAt = new Date().toISOString();
318
+ if (reply) await this.#bot.sendText(message.replyTarget, reply);
319
+ this.#status.lastError = null;
320
+ })().catch(async (error) => {
321
+ if (this.#signal?.aborted) return;
322
+ this.#status.lastError = error?.message ?? String(error);
323
+ this.#logger.error?.(
324
+ `[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
325
+ error,
326
+ );
327
+ }).finally(() => {
328
+ this.#acceptedMessageIds.delete(messageId);
329
+ this.#commandTasks.delete(task);
330
+ });
331
+ this.#commandTasks.add(task);
332
+ return task;
333
+ }
334
+
257
335
  #enqueueMessage(message, messageId, senderId, key, {
258
336
  releaseMessageId = true,
259
337
  alreadyRecorded = false,
@@ -373,6 +451,7 @@ export class TextHarnessBridge {
373
451
 
374
452
  const target = message.replyTarget;
375
453
  const text = cleanText(message.content);
454
+ const batchSubmission = message.batchSubmission;
376
455
  let stream = null;
377
456
  let semanticStream = false;
378
457
  try {
@@ -411,7 +490,11 @@ export class TextHarnessBridge {
411
490
  t('/preset --default 跟随 Host 默认'),
412
491
  t('/stop 停止当前任务'),
413
492
  t('/steer 补充指令 纠偏当前任务'),
493
+ t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
494
+ t('/send 提交当前批次'),
495
+ t('/cancel 取消当前批次'),
414
496
  t('/status 检查连接状态'),
497
+ t('/version 查看插件版本'),
415
498
  t('/help 显示本帮助'),
416
499
  ].join('\n'));
417
500
  return;
@@ -508,6 +591,9 @@ export class TextHarnessBridge {
508
591
  files: message.files,
509
592
  },
510
593
  });
594
+ if (batchSubmission) {
595
+ this.#batches.complete(conversationKey, batchSubmission.token);
596
+ }
511
597
  const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
512
598
  const visibleAnswer = fileOnlyCompletion
513
599
  ? t(FILE_ONLY_COMPLETION_TEXT)
@@ -579,7 +665,14 @@ export class TextHarnessBridge {
579
665
  }
580
666
  return delivery.receipt;
581
667
  } catch (error) {
582
- if (error?.code === 'turn-stopped') {
668
+ const turnStopped = error?.code === 'turn-stopped';
669
+ if (batchSubmission && turnStopped) {
670
+ this.#batches.complete(conversationKey, batchSubmission.token);
671
+ }
672
+ const failedBatch = batchSubmission && !turnStopped
673
+ ? this.#batches.fail(conversationKey, batchSubmission.token)
674
+ : null;
675
+ if (turnStopped) {
583
676
  if (stream) {
584
677
  try {
585
678
  await stream.finish(t('已停止。'));
@@ -607,6 +700,23 @@ export class TextHarnessBridge {
607
700
  return false;
608
701
  }
609
702
  };
703
+ if (failedBatch?.retained) {
704
+ this.#logger.error?.(
705
+ `[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
706
+ error,
707
+ );
708
+ if (await presentStreamFailure(failedBatch.message)) return;
709
+ stream?.cancel?.();
710
+ try {
711
+ await this.#bot.sendText(target, failedBatch.message);
712
+ } catch (sendError) {
713
+ this.#logger.error?.(
714
+ `[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
715
+ sendError,
716
+ );
717
+ }
718
+ return;
719
+ }
610
720
  const imageErrorMessage = imagePromptUserMessage(error);
611
721
  if (imageErrorMessage) {
612
722
  if (await presentStreamFailure(imageErrorMessage)) return;
@@ -119,6 +119,7 @@ export function normalizeSlackEvent(payload, botUserId, {
119
119
  kind: direct ? 'direct' : 'group',
120
120
  conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
121
121
  content: stripBotMention(event.text ?? '', botUserId),
122
+ plainText: !Array.isArray(event.files) || event.files.length === 0,
122
123
  images: Array.isArray(event.files)
123
124
  ? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
124
125
  : [],
@@ -28,7 +28,11 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
28
28
  { command: 'preset', description: '查看或设置新会话 Agent Preset' },
29
29
  { command: 'stop', description: '停止当前任务' },
30
30
  { command: 'steer', description: '纠偏当前任务' },
31
+ { command: 'batch', description: '开始批量输入(仅私聊)' },
32
+ { command: 'send', description: '提交当前批次' },
33
+ { command: 'cancel', description: '取消当前批次' },
31
34
  { command: 'status', description: '检查连接状态' },
35
+ { command: 'version', description: '查看插件版本' },
32
36
  { command: 'help', description: '显示帮助' },
33
37
  ]);
34
38
 
@@ -160,6 +164,7 @@ export function normalizeTelegramUpdate(update, {
160
164
  conversationId: messageThreadId === undefined
161
165
  ? String(chatId) : `${chatId}:${messageThreadId}`,
162
166
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
167
+ plainText: typeof message.text === 'string',
163
168
  images: image ? [image] : [],
164
169
  files: file ? [file] : [],
165
170
  addressed,
@@ -5,6 +5,12 @@ import {
5
5
  validHarnessQuestion,
6
6
  } from '../shared/harness-question.mjs';
7
7
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
8
+ import {
9
+ BatchInputManager,
10
+ batchInputBusyMessage,
11
+ batchInputGroupUnsupportedMessage,
12
+ isBatchInputCommand,
13
+ } from '../shared/batch-input.mjs';
8
14
  import { runCompactCommand } from '../shared/compact-command.mjs';
9
15
  import {
10
16
  isControlCommand,
@@ -63,7 +69,11 @@ function helpText() {
63
69
  t('/preset --default 跟随 Host 默认'),
64
70
  t('/stop 停止当前任务'),
65
71
  t('/steer 补充指令 纠偏当前任务'),
72
+ t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
73
+ t('/send 提交当前批次'),
74
+ t('/cancel 取消当前批次'),
66
75
  t('/status 检查连接状态'),
76
+ t('/version 查看插件版本'),
67
77
  t('/help 显示本帮助'),
68
78
  ].join('\n');
69
79
  }
@@ -254,6 +264,10 @@ function interactionReplyText(frame) {
254
264
  return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
255
265
  }
256
266
 
267
+ function isNativeWecomText(frame) {
268
+ return bodyOf(frame).msgtype === 'text' && Boolean(nonEmptyString(messageText(frame)));
269
+ }
270
+
257
271
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
258
272
  const source = String(text ?? '').trim();
259
273
  if (!source) return [];
@@ -466,6 +480,7 @@ export class WecomHarnessBridge {
466
480
  #approvalTasks = new Set();
467
481
  #commandTasks = new Set();
468
482
  #approvals;
483
+ #batchInputs = new BatchInputManager();
469
484
  #prefetchedImageCount = 0;
470
485
 
471
486
  constructor({
@@ -523,6 +538,40 @@ export class WecomHarnessBridge {
523
538
  const pending = this.#pendingInteractions.get(key);
524
539
  const commandMessage = wecomInboundMessage(frame, this.#client);
525
540
  const commandText = nonEmptyString(commandMessage.content) ?? '';
541
+ const batchCommand = isBatchInputCommand(commandText);
542
+ const batchStatus = this.#batchInputs.status(key);
543
+ if (batchCommand && body.chattype === 'group') {
544
+ return this.#finishBatchResult(
545
+ frame,
546
+ messageId,
547
+ chatId,
548
+ { message: batchInputGroupUnsupportedMessage() },
549
+ );
550
+ }
551
+ if (body.chattype === 'single'
552
+ && (batchCommand || batchStatus.phase === 'collecting')) {
553
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
554
+ const result = exactBatchStart
555
+ && batchStatus.phase === 'idle'
556
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
557
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
558
+ : this.#batchInputs.handle(key, commandText, {
559
+ plainText: isNativeWecomText(frame),
560
+ });
561
+ if (result.handled) {
562
+ if (result.kind === 'submit') {
563
+ return this.#enqueueMessage({
564
+ ...frame,
565
+ body: {
566
+ ...body,
567
+ msgtype: 'text',
568
+ text: { content: result.prompt },
569
+ },
570
+ }, messageId, key, { batchSubmission: result });
571
+ }
572
+ return this.#finishBatchResult(frame, messageId, chatId, result);
573
+ }
574
+ }
526
575
  const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
527
576
  ? runControlCommand
528
577
  : (isModelCommand(commandText)
@@ -616,6 +665,7 @@ export class WecomHarnessBridge {
616
665
  #enqueueMessage(frame, messageId, key, {
617
666
  releaseMessageId = true,
618
667
  alreadyRecorded = false,
668
+ batchSubmission = null,
619
669
  } = {}) {
620
670
  // WeCom image URLs expire after five minutes, while a conversation turn
621
671
  // may legally stay queued longer. Start the authenticated SDK download as
@@ -637,7 +687,11 @@ export class WecomHarnessBridge {
637
687
  const previous = this.#queues.get(key) ?? Promise.resolve();
638
688
  const current = previous
639
689
  .catch(() => undefined)
640
- .then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
690
+ .then(() => this.#process(frame, {
691
+ alreadyRecorded,
692
+ preparedMessage,
693
+ batchSubmission,
694
+ }))
641
695
  .finally(() => {
642
696
  this.#prefetchedImageCount -= reservedImages;
643
697
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -647,6 +701,29 @@ export class WecomHarnessBridge {
647
701
  return current;
648
702
  }
649
703
 
704
+ #finishBatchResult(frame, messageId, chatId, result) {
705
+ let task;
706
+ task = Promise.resolve().then(async () => {
707
+ if (this.#state.hasSeen(messageId)) return;
708
+ await this.#state.markSeen(messageId);
709
+ this.#status.messagesReceived += 1;
710
+ this.#status.lastMessageAt = new Date().toISOString();
711
+ if (result.message) await this.#sendImmediate(frame, chatId, result.message);
712
+ this.#status.lastError = null;
713
+ }).catch(async (error) => {
714
+ if (this.#signal?.aborted) return;
715
+ this.#status.lastError = error?.message ?? String(error);
716
+ this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
717
+ await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
718
+ .catch(() => undefined);
719
+ }).finally(() => {
720
+ this.#acceptedMessageIds.delete(messageId);
721
+ this.#commandTasks.delete(task);
722
+ });
723
+ this.#commandTasks.add(task);
724
+ return task;
725
+ }
726
+
650
727
  async waitForIdle() {
651
728
  await Promise.allSettled([
652
729
  ...this.#queues.values(),
@@ -748,7 +825,11 @@ export class WecomHarnessBridge {
748
825
  };
749
826
  }
750
827
 
751
- async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
828
+ async #process(frame, {
829
+ alreadyRecorded = false,
830
+ preparedMessage,
831
+ batchSubmission = null,
832
+ } = {}) {
752
833
  if (this.#signal?.aborted) return;
753
834
  const body = bodyOf(frame);
754
835
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
@@ -767,6 +848,7 @@ export class WecomHarnessBridge {
767
848
  const key = conversationKey(frame);
768
849
  let streamId = null;
769
850
  let streamStarted = false;
851
+ let batchSettled = batchSubmission === null;
770
852
  try {
771
853
  if (!text && !hasImages && !hasFiles) {
772
854
  await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
@@ -855,6 +937,10 @@ export class WecomHarnessBridge {
855
937
  files: message.files,
856
938
  },
857
939
  });
940
+ if (batchSubmission) {
941
+ this.#batchInputs.complete(key, batchSubmission.token);
942
+ batchSettled = true;
943
+ }
858
944
 
859
945
  this.#signal?.throwIfAborted();
860
946
  const displayAnswer = answerTextForDelivery(answer, artifacts);
@@ -915,6 +1001,15 @@ export class WecomHarnessBridge {
915
1001
  this.#status.lastError = null;
916
1002
  return delivery.receipt;
917
1003
  } catch (error) {
1004
+ let batchFailureMessage = null;
1005
+ if (!batchSettled && batchSubmission) {
1006
+ if (error?.code === 'turn-stopped') {
1007
+ this.#batchInputs.complete(key, batchSubmission.token);
1008
+ } else {
1009
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
1010
+ }
1011
+ batchSettled = true;
1012
+ }
918
1013
  if (error?.code === 'turn-stopped') {
919
1014
  if (streamStarted && streamId) {
920
1015
  await this.#client.replyStream(frame, streamId, t('已停止。'), true)
@@ -929,11 +1024,14 @@ export class WecomHarnessBridge {
929
1024
  const errorText = inboundFileUserMessage(error)
930
1025
  ?? imagePromptUserMessage(error)
931
1026
  ?? t('消息处理失败,请稍后重试。');
1027
+ const visibleError = batchFailureMessage
1028
+ ? `${errorText}\n\n${batchFailureMessage}`
1029
+ : errorText;
932
1030
  try {
933
1031
  if (streamStarted && streamId) {
934
- await this.#client.replyStream(frame, streamId, errorText, true);
1032
+ await this.#client.replyStream(frame, streamId, visibleError, true);
935
1033
  } else {
936
- await this.#sendImmediate(frame, chatId, errorText);
1034
+ await this.#sendImmediate(frame, chatId, visibleError);
937
1035
  }
938
1036
  await this.#state.markSeen(messageId);
939
1037
  } catch {
@@ -12,6 +12,11 @@ import {
12
12
  validHarnessQuestion,
13
13
  } from '../shared/harness-question.mjs';
14
14
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
15
+ import {
16
+ BatchInputManager,
17
+ batchInputBusyMessage,
18
+ isBatchInputCommand,
19
+ } from '../shared/batch-input.mjs';
15
20
  import { runCompactCommand } from '../shared/compact-command.mjs';
16
21
  import {
17
22
  isControlCommand,
@@ -70,7 +75,11 @@ const HELP_TEXT = () => [
70
75
  t('/preset --default 跟随 Host 默认'),
71
76
  t('/stop 停止当前任务'),
72
77
  t('/steer 补充指令 纠偏当前任务'),
78
+ t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
79
+ t('/send 提交当前批次'),
80
+ t('/cancel 取消当前批次'),
73
81
  t('/status 检查连接状态'),
82
+ t('/version 查看插件版本'),
74
83
  t('/help 显示本帮助'),
75
84
  ].join('\n');
76
85
 
@@ -104,6 +113,15 @@ function hasWeixinFileItems(message) {
104
113
  && message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
105
114
  }
106
115
 
116
+ function isNativeWeixinText(message) {
117
+ return Array.isArray(message?.item_list)
118
+ && message.item_list.length > 0
119
+ && message.item_list.every((item) => (
120
+ item?.type === 1 && typeof item.text_item?.text === 'string'
121
+ ))
122
+ && Boolean(nonEmptyString(extractWeixinText(message)));
123
+ }
124
+
107
125
  function canClaimInteractionReply(message, pending) {
108
126
  return pending.questions[pending.index]
109
127
  && nonEmptyString(message?.from_user_id) === pending.actor
@@ -176,6 +194,7 @@ export class WeixinHarnessBridge {
176
194
  #approvalTasks = new Set();
177
195
  #commandTasks = new Set();
178
196
  #approvals;
197
+ #batchInputs = new BatchInputManager();
179
198
 
180
199
  constructor({
181
200
  api,
@@ -227,6 +246,35 @@ export class WeixinHarnessBridge {
227
246
  const runId = nonEmptyString(message?.run_id) ?? undefined;
228
247
  const pending = this.#pendingInteractions.get(key);
229
248
  const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
249
+ const batchCommand = isBatchInputCommand(commandText);
250
+ const batchStatus = this.#batchInputs.status(key);
251
+ if (sender === this.#ownerUserId
252
+ && (batchCommand || batchStatus.phase === 'collecting')) {
253
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
254
+ const result = exactBatchStart
255
+ && batchStatus.phase === 'idle'
256
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
257
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
258
+ : this.#batchInputs.handle(key, commandText, {
259
+ plainText: isNativeWeixinText(message),
260
+ });
261
+ if (result.handled) {
262
+ if (result.kind === 'submit') {
263
+ return this.#enqueueMessage({
264
+ ...message,
265
+ item_list: [{ type: 1, text_item: { text: result.prompt } }],
266
+ }, messageId, key, { batchSubmission: result });
267
+ }
268
+ return this.#finishBatchResult(
269
+ message,
270
+ messageId,
271
+ sender,
272
+ contextToken,
273
+ runId,
274
+ result,
275
+ );
276
+ }
277
+ }
230
278
  const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
231
279
  ? runControlCommand
232
280
  : (isModelCommand(commandText)
@@ -314,6 +362,7 @@ export class WeixinHarnessBridge {
314
362
  #enqueueMessage(message, messageId, key, {
315
363
  releaseMessageId = true,
316
364
  alreadyRecorded = false,
365
+ batchSubmission = null,
317
366
  } = {}) {
318
367
  const preparedMessage = message.from_user_id === this.#ownerUserId
319
368
  ? prefetchInboundFiles(
@@ -324,7 +373,11 @@ export class WeixinHarnessBridge {
324
373
  const previous = this.#queues.get(key) ?? Promise.resolve();
325
374
  const current = previous
326
375
  .catch(() => undefined)
327
- .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
376
+ .then(() => this.#process(message, key, {
377
+ alreadyRecorded,
378
+ preparedMessage,
379
+ batchSubmission,
380
+ }))
328
381
  .finally(() => {
329
382
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
330
383
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -333,6 +386,33 @@ export class WeixinHarnessBridge {
333
386
  return current;
334
387
  }
335
388
 
389
+ #finishBatchResult(message, messageId, sender, contextToken, runId, result) {
390
+ let task;
391
+ task = Promise.resolve().then(async () => {
392
+ if (this.#state.hasSeen(messageId)) return;
393
+ await this.#state.markSeen(messageId);
394
+ this.#status.messagesReceived += 1;
395
+ this.#status.lastMessageAt = new Date().toISOString();
396
+ if (result.message) {
397
+ await this.#send(sender, result.message, contextToken, runId);
398
+ }
399
+ this.#status.lastError = null;
400
+ this.#status.lastMessageError = null;
401
+ }).catch(async (error) => {
402
+ if (this.#signal?.aborted) return;
403
+ this.#status.lastError = error?.message ?? String(error);
404
+ this.#status.lastMessageError = safeMessageError(error);
405
+ this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
406
+ await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
407
+ .catch(() => undefined);
408
+ }).finally(() => {
409
+ this.#acceptedMessageIds.delete(messageId);
410
+ this.#commandTasks.delete(task);
411
+ });
412
+ this.#commandTasks.add(task);
413
+ return task;
414
+ }
415
+
336
416
  async waitForIdle() {
337
417
  await Promise.allSettled([
338
418
  ...this.#queues.values(),
@@ -380,7 +460,11 @@ export class WeixinHarnessBridge {
380
460
  this.#status.lastMessageError = null;
381
461
  }
382
462
 
383
- async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
463
+ async #process(message, key, {
464
+ alreadyRecorded = false,
465
+ preparedMessage,
466
+ batchSubmission = null,
467
+ } = {}) {
384
468
  this.#signal?.throwIfAborted();
385
469
  const messageId = weixinMessageId(message);
386
470
  const sender = nonEmptyString(message?.from_user_id);
@@ -398,6 +482,7 @@ export class WeixinHarnessBridge {
398
482
 
399
483
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
400
484
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
485
+ let batchSettled = batchSubmission === null;
401
486
  try {
402
487
  const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
403
488
  const text = promptMessage.content;
@@ -479,6 +564,10 @@ export class WeixinHarnessBridge {
479
564
  files: promptMessage.files,
480
565
  },
481
566
  }));
567
+ if (batchSubmission) {
568
+ this.#batchInputs.complete(key, batchSubmission.token);
569
+ batchSettled = true;
570
+ }
482
571
  } finally {
483
572
  await Promise.allSettled([
484
573
  this.#cancelPendingInteraction(key),
@@ -515,6 +604,15 @@ export class WeixinHarnessBridge {
515
604
  this.#status.lastMessageError = null;
516
605
  return delivery.receipt;
517
606
  } catch (error) {
607
+ let batchFailureMessage = null;
608
+ if (!batchSettled && batchSubmission) {
609
+ if (error?.code === 'turn-stopped') {
610
+ this.#batchInputs.complete(key, batchSubmission.token);
611
+ } else {
612
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
613
+ }
614
+ batchSettled = true;
615
+ }
518
616
  if (error?.code === 'turn-stopped') {
519
617
  await this.#state.markSeen(messageId);
520
618
  return;
@@ -529,7 +627,7 @@ export class WeixinHarnessBridge {
529
627
  try {
530
628
  await this.#send(
531
629
  sender,
532
- userMessage,
630
+ batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
533
631
  contextToken,
534
632
  runId,
535
633
  );