@xmanrui/dsh-im 2.2.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -99,7 +99,7 @@ export function WhatsappAccessSettings({ account, busy = false, onSave }) {
99
99
  h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
100
100
  h('span', { className: 'dwa-accessTooltipItem' },
101
101
  h('strong', null, '开放响应模式'),
102
- h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
102
+ h('span', null, '响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。')))))),
103
103
  h('label', { className: 'dwa-accessField' },
104
104
  h('span', null, '模式'),
105
105
  h('select', {
@@ -373,7 +373,7 @@ const EN = Object.freeze({
373
373
  '已生效:': 'Active: ',
374
374
  '只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
375
375
  '响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
376
- '响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
376
+ '响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。': 'Respond to all direct messages, group messages sent by the linked account, and mentions or replies from other group members.',
377
377
  '允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
378
378
  '每行一个含国家或地区代码的号码': 'One number with country or region code per line',
379
379
  '可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
@@ -24,6 +24,12 @@ import {
24
24
  } from '../shared/preset-command.mjs';
25
25
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
26
26
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
27
+ import {
28
+ BatchInputManager,
29
+ batchInputBusyMessage,
30
+ batchInputGroupUnsupportedMessage,
31
+ isBatchInputCommand,
32
+ } from '../shared/batch-input.mjs';
27
33
  import {
28
34
  hasInboundImages,
29
35
  imagePromptUserMessage,
@@ -67,6 +73,9 @@ const HELP_TEXT_LINES = [
67
73
  '/preset --default 跟随 Host 默认',
68
74
  '/stop 停止当前任务',
69
75
  '/steer 补充指令 纠偏当前任务',
76
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)',
77
+ '/send 提交当前批次',
78
+ '/cancel 取消当前批次',
70
79
  '/status 检查连接状态',
71
80
  '/help 显示本帮助',
72
81
  ];
@@ -344,6 +353,7 @@ export class DingtalkHarnessBridge {
344
353
  #commandTasks = new Set();
345
354
  #acceptedMessageIds = new Set();
346
355
  #approvals;
356
+ #batchInputs = new BatchInputManager();
347
357
 
348
358
  constructor({
349
359
  api,
@@ -416,12 +426,52 @@ export class DingtalkHarnessBridge {
416
426
  clientSecret: this.#clientSecret,
417
427
  });
418
428
  const commandText = nonEmptyString(promptMessage.content) ?? '';
429
+ const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
430
+ const direct = String(message.conversationType) !== '2';
431
+ const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
432
+ && isBatchInputCommand(commandText);
433
+ const batchStatus = this.#batchInputs.status(key);
434
+ if (batchCommand && !direct && sessionWebhook && addressed) {
435
+ return this.#finishBatchResult(
436
+ messageId,
437
+ sessionWebhook,
438
+ { message: batchInputGroupUnsupportedMessage() },
439
+ );
440
+ }
441
+ if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
442
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
443
+ const result = exactBatchStart
444
+ && batchStatus.phase === 'idle'
445
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
446
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
447
+ : this.#batchInputs.handle(key, commandText, {
448
+ plainText: Boolean(commandText)
449
+ && String(message?.msgtype).toLowerCase() === 'text'
450
+ && !hasInboundFiles(promptMessage)
451
+ && !hasInboundImages(promptMessage),
452
+ });
453
+ if (result.handled) {
454
+ if (result.kind === 'submit') {
455
+ return this.#enqueueMessage(
456
+ {
457
+ ...message,
458
+ msgtype: 'text',
459
+ text: { content: result.prompt },
460
+ },
461
+ messageId,
462
+ sender,
463
+ key,
464
+ { batchSubmission: result },
465
+ );
466
+ }
467
+ return this.#finishBatchResult(messageId, sessionWebhook, result);
468
+ }
469
+ }
419
470
  const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
420
471
  ? runControlCommand
421
472
  : (isModelCommand(commandText)
422
473
  ? runModelCommand
423
474
  : (isPresetCommand(commandText) ? runPresetCommand : null));
424
- const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
425
475
  if (commandRunner && sessionWebhook && addressed) {
426
476
  let task;
427
477
  task = this.#processFastCommand(
@@ -521,6 +571,7 @@ export class DingtalkHarnessBridge {
521
571
  #enqueueMessage(message, messageId, sender, key, {
522
572
  releaseMessageId = true,
523
573
  alreadyRecorded = false,
574
+ batchSubmission = null,
524
575
  } = {}) {
525
576
  let hasSafeReplyRoute = false;
526
577
  try {
@@ -543,6 +594,7 @@ export class DingtalkHarnessBridge {
543
594
  .then(() => this.#process(message, messageId, sender, key, {
544
595
  alreadyRecorded,
545
596
  preparedMessage,
597
+ batchSubmission,
546
598
  }))
547
599
  .finally(() => {
548
600
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -595,9 +647,32 @@ export class DingtalkHarnessBridge {
595
647
  this.#status.lastError = null;
596
648
  }
597
649
 
650
+ #finishBatchResult(messageId, sessionWebhook, result) {
651
+ let task;
652
+ task = Promise.resolve().then(async () => {
653
+ if (this.#state.hasSeen(messageId)) return;
654
+ await this.#state.markSeen(messageId);
655
+ increment(this.#status, 'messagesReceived');
656
+ this.#status.lastMessageAt = new Date().toISOString();
657
+ if (result.message) await this.#send(sessionWebhook, result.message);
658
+ this.#status.lastError = null;
659
+ }).catch(async (error) => {
660
+ if (this.#signal?.aborted) return;
661
+ this.#status.lastError = t('钉钉命令处理失败。');
662
+ this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
663
+ await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
664
+ }).finally(() => {
665
+ this.#acceptedMessageIds.delete(messageId);
666
+ this.#commandTasks.delete(task);
667
+ });
668
+ this.#commandTasks.add(task);
669
+ return task;
670
+ }
671
+
598
672
  async #process(message, messageId, sender, key, {
599
673
  alreadyRecorded = false,
600
674
  preparedMessage,
675
+ batchSubmission = null,
601
676
  } = {}) {
602
677
  this.#signal?.throwIfAborted();
603
678
  if (!alreadyRecorded) {
@@ -633,6 +708,7 @@ export class DingtalkHarnessBridge {
633
708
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
634
709
  let cardStream = null;
635
710
  let cardStarted = false;
711
+ let batchSettled = batchSubmission === null;
636
712
  try {
637
713
  if (!text && !hasImages && !hasFiles) {
638
714
  await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
@@ -717,6 +793,10 @@ export class DingtalkHarnessBridge {
717
793
  files: promptMessage.files,
718
794
  },
719
795
  });
796
+ if (batchSubmission) {
797
+ this.#batchInputs.complete(key, batchSubmission.token);
798
+ batchSettled = true;
799
+ }
720
800
  const answerText = typeof answer === 'string' && answer.trim()
721
801
  ? answer
722
802
  : artifacts.length > 0 ? t('结果文件已生成。') : answer;
@@ -753,6 +833,15 @@ export class DingtalkHarnessBridge {
753
833
  this.#status.lastError = null;
754
834
  return delivery.receipt;
755
835
  } catch (error) {
836
+ let batchFailureMessage = null;
837
+ if (!batchSettled && batchSubmission) {
838
+ if (error?.code === 'turn-stopped') {
839
+ this.#batchInputs.complete(key, batchSubmission.token);
840
+ } else {
841
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
842
+ }
843
+ batchSettled = true;
844
+ }
756
845
  if (error?.code === 'turn-stopped') {
757
846
  if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
758
847
  return;
@@ -767,8 +856,11 @@ export class DingtalkHarnessBridge {
767
856
  const errorText = inboundFileUserMessage(error)
768
857
  ?? dingtalkImageErrorUserMessage(error)
769
858
  ?? t(CARD_ERROR_TEXT);
770
- const streamed = cardStarted && await cardStream.finish(errorText);
771
- if (!streamed) await this.#send(sessionWebhook, errorText);
859
+ const visibleError = batchFailureMessage
860
+ ? `${errorText}\n\n${batchFailureMessage}`
861
+ : errorText;
862
+ const streamed = cardStarted && await cardStream.finish(visibleError);
863
+ if (!streamed) await this.#send(sessionWebhook, visibleError);
772
864
  } catch {
773
865
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
774
866
  }
@@ -217,6 +217,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
217
217
  kind: direct ? 'direct' : 'group',
218
218
  conversationId: String(message.channel_id),
219
219
  content: stripBotMention(message.content ?? '', botId),
220
+ plainText: (!Array.isArray(message.attachments) || message.attachments.length === 0)
221
+ && (!Array.isArray(message.sticker_items) || message.sticker_items.length === 0)
222
+ && !message.poll,
220
223
  images: Array.isArray(message.attachments)
221
224
  ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
222
225
  : [],
@@ -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,
@@ -366,6 +372,7 @@ export class FeishuHarnessBridge {
366
372
  #harness;
367
373
  #state;
368
374
  #queues = new Map();
375
+ #batchInputs = new BatchInputManager();
369
376
  #pendingInteractions = new Map();
370
377
  #interactionKeys = new Map();
371
378
  #resolvedQuestionReplies = new Map();
@@ -542,6 +549,58 @@ export class FeishuHarnessBridge {
542
549
  const processingReaction = this.#addReaction(messageId, 'OnIt');
543
550
  const commandMessage = extractInboundMessage(event, this.#client);
544
551
  const commandText = nonEmptyString(commandMessage.content) ?? '';
552
+ const batchText = event.message.message_type === 'text'
553
+ ? nonEmptyString(extractText(event)) ?? ''
554
+ : '';
555
+ const batchCommand = event.message.message_type === 'text'
556
+ && isBatchInputCommand(batchText);
557
+ const pending = this.#pendingInteractions.get(key);
558
+ const batchStatus = this.#batchInputs.status(key);
559
+ if (batchCommand && event.message.chat_type !== 'p2p') {
560
+ return this.#finishBatchResult(
561
+ event,
562
+ messageId,
563
+ processingReaction,
564
+ { message: batchInputGroupUnsupportedMessage() },
565
+ );
566
+ }
567
+ if (event.message.chat_type === 'p2p'
568
+ && (batchCommand || batchStatus.phase === 'collecting')) {
569
+ const exactBatchStart = /^\/batch$/iu.test(batchText);
570
+ const result = exactBatchStart
571
+ && batchStatus.phase === 'idle'
572
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
573
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
574
+ : this.#batchInputs.handle(key, batchText, {
575
+ plainText: event.message.message_type === 'text' && Boolean(batchText),
576
+ });
577
+ if (result.handled) {
578
+ if (result.kind === 'submit') {
579
+ const submissionEvent = {
580
+ ...event,
581
+ batchSubmission: { token: result.token },
582
+ message: {
583
+ ...event.message,
584
+ message_type: 'text',
585
+ content: JSON.stringify({ text: result.prompt }),
586
+ mentions: [],
587
+ },
588
+ };
589
+ return this.#enqueueMessage(
590
+ submissionEvent,
591
+ messageId,
592
+ key,
593
+ processingReaction,
594
+ );
595
+ }
596
+ return this.#finishBatchResult(
597
+ event,
598
+ messageId,
599
+ processingReaction,
600
+ result,
601
+ );
602
+ }
603
+ }
545
604
  // Card commands (/m, /help, /status, etc.) bypass the queue so they
546
605
  // respond immediately even when a harness task is still streaming.
547
606
  if (CARD_COMMAND.test(commandText)) {
@@ -599,7 +658,6 @@ export class FeishuHarnessBridge {
599
658
  .finally(() => this.#acceptedMessageIds.delete(messageId));
600
659
  return current;
601
660
  }
602
- const pending = this.#pendingInteractions.get(key);
603
661
  const approvalReply = this.#approvals.claimReply({
604
662
  key,
605
663
  actor: senderOpenId(event),
@@ -691,6 +749,32 @@ export class FeishuHarnessBridge {
691
749
  return this.#enqueueMessage(event, messageId, key, processingReaction);
692
750
  }
693
751
 
752
+ #finishBatchResult(event, messageId, processingReaction, result) {
753
+ let current;
754
+ current = Promise.resolve()
755
+ .then(async () => {
756
+ if (this.#state.hasSeen(messageId)) return;
757
+ await this.#state.markSeen(messageId);
758
+ this.#status.lastMessageAt = new Date().toISOString();
759
+ this.#status.messagesReceived += 1;
760
+ if (result?.message) await this.#send(event.message.chat_id, result.message);
761
+ this.#status.lastError = null;
762
+ })
763
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
764
+ .catch((error) => this.#handleMessageFailure(
765
+ event,
766
+ messageId,
767
+ processingReaction,
768
+ error,
769
+ ))
770
+ .finally(() => {
771
+ this.#acceptedMessageIds.delete(messageId);
772
+ this.#commandTasks.delete(current);
773
+ });
774
+ this.#commandTasks.add(current);
775
+ return current;
776
+ }
777
+
694
778
  #enqueueMessage(event, messageId, key, processingReaction, {
695
779
  releaseMessageId = true,
696
780
  alreadyRecorded = false,
@@ -725,6 +809,9 @@ export class FeishuHarnessBridge {
725
809
  async #handleMessageFailure(event, messageId, processingReaction, error) {
726
810
  if (error?.code === 'turn-stopped') {
727
811
  await this.#removeProcessingReaction(messageId, processingReaction);
812
+ if (error?.batchInputMessage) {
813
+ await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
814
+ }
728
815
  return;
729
816
  }
730
817
  if (this.#signal?.aborted) {
@@ -736,7 +823,8 @@ export class FeishuHarnessBridge {
736
823
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
737
824
  await this.#send(
738
825
  event.message.chat_id,
739
- inboundFileUserMessage(error)
826
+ error?.batchInputMessage
827
+ ?? inboundFileUserMessage(error)
740
828
  ?? imagePromptUserMessage(error)
741
829
  ?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
742
830
  ).catch(() => undefined);
@@ -939,12 +1027,38 @@ export class FeishuHarnessBridge {
939
1027
  }
940
1028
 
941
1029
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
1030
+ const batchSubmission = event.batchSubmission ?? null;
1031
+ let batchAskCompleted = false;
942
1032
  try {
943
- const receipt = await this.#answerWithStream(event, key, message);
1033
+ const receipt = await this.#answerWithStream(event, key, message, {
1034
+ onAskComplete: batchSubmission
1035
+ ? () => {
1036
+ batchAskCompleted = this.#batchInputs.complete(
1037
+ key,
1038
+ batchSubmission.token,
1039
+ ).completed;
1040
+ }
1041
+ : undefined,
1042
+ });
944
1043
  this.#status.messagesReplied += 1;
945
1044
  this.#status.lastReplyAt = new Date().toISOString();
946
1045
  this.#status.lastError = null;
947
1046
  return receipt;
1047
+ } catch (error) {
1048
+ if (batchSubmission && !batchAskCompleted) {
1049
+ if (error?.code === 'turn-stopped') {
1050
+ this.#batchInputs.complete(key, batchSubmission.token);
1051
+ throw error;
1052
+ }
1053
+ const failed = this.#batchInputs.fail(key, batchSubmission.token);
1054
+ if (failed.retained) {
1055
+ const batchError = new Error(error?.message ?? String(error), { cause: error });
1056
+ batchError.code = error?.code;
1057
+ batchError.batchInputMessage = failed.message;
1058
+ throw batchError;
1059
+ }
1060
+ }
1061
+ throw error;
948
1062
  } finally {
949
1063
  await this.#cancelPendingInteraction(key);
950
1064
  await this.#approvals.closeRoute(key);
@@ -2937,10 +3051,16 @@ export class FeishuHarnessBridge {
2937
3051
  };
2938
3052
  }
2939
3053
 
2940
- async #answerWithStream(event, key, message) {
3054
+ async #answerWithStream(event, key, message, { onAskComplete } = {}) {
2941
3055
  const chatId = event.message.chat_id;
2942
3056
  const messageId = event.message.message_id;
2943
3057
  const text = message.content;
3058
+ let askCompleted = false;
3059
+ const markAskComplete = () => {
3060
+ if (askCompleted) return;
3061
+ askCompleted = true;
3062
+ onAskComplete?.();
3063
+ };
2944
3064
  const content = hasInboundImages(message)
2945
3065
  ? await promptContentForMessage(message, { signal: this.#signal })
2946
3066
  : undefined;
@@ -2955,6 +3075,7 @@ export class FeishuHarnessBridge {
2955
3075
  existsOptions: { signal: this.#signal },
2956
3076
  askOptions: this.#interactionAskOptions(event, key, message.files),
2957
3077
  });
3078
+ markAskComplete();
2958
3079
  let textReceipt;
2959
3080
  let textSendError = null;
2960
3081
  try {
@@ -3009,6 +3130,7 @@ export class FeishuHarnessBridge {
3009
3130
  existsOptions: { signal: this.#signal },
3010
3131
  askOptions,
3011
3132
  });
3133
+ markAskComplete();
3012
3134
  completedAnswer = completed.answer;
3013
3135
  completedArtifacts = completed.artifacts ?? [];
3014
3136
  await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
@@ -3067,6 +3189,7 @@ export class FeishuHarnessBridge {
3067
3189
  existsOptions: { signal: this.#signal },
3068
3190
  askOptions: this.#interactionAskOptions(event, key, message.files),
3069
3191
  });
3192
+ markAskComplete();
3070
3193
  let textReceipt;
3071
3194
  let textSendError = null;
3072
3195
  try {
@@ -513,6 +513,11 @@ export function menuHelpText() {
513
513
  '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
514
514
  '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
515
515
  '',
516
+ '📦 批量输入(仅私聊)',
517
+ '/batch 开始批量输入(仅私聊,最多 10 条文字)',
518
+ '/send 提交当前批次',
519
+ '/cancel 取消当前批次',
520
+ '',
516
521
  '🎮 任务控制',
517
522
  '/stop 停止当前任务',
518
523
  '/steer 指令 给 Agent 补充指令',
@@ -564,6 +569,9 @@ const HELP_TEXT_COMMANDS = [
564
569
  '`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
565
570
  '`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
566
571
  '`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
572
+ '`/batch` — 开启批量输入(仅私聊,最多 10 条文字)',
573
+ '`/send` — 提交当前批次',
574
+ '`/cancel` — 取消当前批次',
567
575
  '`/repair` — 修复卡片按钮',
568
576
  ].join('\n');
569
577
 
@@ -20,6 +20,12 @@ import {
20
20
  runPresetCommand,
21
21
  } from '../shared/preset-command.mjs';
22
22
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
23
+ import {
24
+ BatchInputManager,
25
+ batchInputBusyMessage,
26
+ batchInputGroupUnsupportedMessage,
27
+ isBatchInputCommand,
28
+ } from '../shared/batch-input.mjs';
23
29
  import {
24
30
  fetchImageBuffer,
25
31
  hasInboundImages,
@@ -80,6 +86,9 @@ function helpText() {
80
86
  t('/preset --default 跟随 Host 默认'),
81
87
  t('/stop 停止当前任务'),
82
88
  t('/steer 补充指令 纠偏当前任务'),
89
+ t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
90
+ t('/send 提交当前批次'),
91
+ t('/cancel 取消当前批次'),
83
92
  t('/status 检查连接状态'),
84
93
  t('/help 显示本帮助'),
85
94
  ].join('\n');
@@ -352,6 +361,7 @@ export class QqHarnessBridge {
352
361
  #approvalTasks = new Set();
353
362
  #commandTasks = new Set();
354
363
  #approvals;
364
+ #batchInputs = new BatchInputManager();
355
365
 
356
366
  constructor({
357
367
  bot,
@@ -407,14 +417,47 @@ export class QqHarnessBridge {
407
417
  }
408
418
  const pending = this.#pendingInteractions.get(key);
409
419
  const commandText = safeText(message);
420
+ const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
421
+ const addressed = message.kind !== 'group'
422
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
423
+ const batchCommand = isBatchInputCommand(commandText);
424
+ const batchStatus = this.#batchInputs.status(key);
425
+ if (batchCommand && allowed && addressed && message.kind === 'group') {
426
+ return this.#finishBatchResult(
427
+ message,
428
+ messageId,
429
+ { message: batchInputGroupUnsupportedMessage() },
430
+ );
431
+ }
432
+ if (allowed && message.kind === 'c2c'
433
+ && (batchCommand || batchStatus.phase === 'collecting')) {
434
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
435
+ const result = exactBatchStart
436
+ && batchStatus.phase === 'idle'
437
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
438
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
439
+ : this.#batchInputs.handle(key, commandText, {
440
+ plainText: Boolean(commandText)
441
+ && !hasQqImageAttachments(message)
442
+ && !hasQqFileAttachments(message),
443
+ });
444
+ if (result.handled) {
445
+ if (result.kind === 'submit') {
446
+ return this.#enqueueMessage(
447
+ { ...message, content: result.prompt, attachments: [] },
448
+ messageId,
449
+ key,
450
+ { batchSubmission: result },
451
+ );
452
+ }
453
+ return this.#finishBatchResult(message, messageId, result);
454
+ }
455
+ }
410
456
  const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
411
457
  ? runControlCommand
412
458
  : (isModelCommand(commandText)
413
459
  ? runModelCommand
414
460
  : (isPresetCommand(commandText) ? runPresetCommand : null));
415
- const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
416
- const addressed = message.kind !== 'group'
417
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
418
461
  if (commandRunner && allowed && addressed) {
419
462
  let task;
420
463
  task = this.#processFastCommand(
@@ -494,6 +537,7 @@ export class QqHarnessBridge {
494
537
  #enqueueMessage(message, messageId, key, {
495
538
  releaseMessageId = true,
496
539
  alreadyRecorded = false,
540
+ batchSubmission = null,
497
541
  } = {}) {
498
542
  const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
499
543
  const addressed = message.kind !== 'group'
@@ -507,7 +551,11 @@ export class QqHarnessBridge {
507
551
  const previous = this.#queues.get(key) ?? Promise.resolve();
508
552
  const current = previous
509
553
  .catch(() => undefined)
510
- .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
554
+ .then(() => this.#process(message, key, {
555
+ alreadyRecorded,
556
+ preparedMessage,
557
+ batchSubmission,
558
+ }))
511
559
  .finally(() => {
512
560
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
513
561
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -553,6 +601,29 @@ export class QqHarnessBridge {
553
601
  this.#status.lastError = null;
554
602
  }
555
603
 
604
+ #finishBatchResult(message, messageId, result) {
605
+ let task;
606
+ task = Promise.resolve().then(async () => {
607
+ if (this.#state.hasSeen(messageId)) return;
608
+ await this.#state.markSeen(messageId);
609
+ this.#status.messagesReceived += 1;
610
+ this.#status.lastMessageAt = new Date().toISOString();
611
+ if (result.message) await this.#bot.sendText(message.replyTarget, result.message);
612
+ this.#status.lastError = null;
613
+ }).catch(async (error) => {
614
+ if (this.#signal?.aborted) return;
615
+ this.#status.lastError = error?.message ?? String(error);
616
+ this.#logger.error?.('[dsh-im:qq] failed to process a batch input message:', error);
617
+ await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
618
+ .catch(() => undefined);
619
+ }).finally(() => {
620
+ this.#acceptedMessageIds.delete(messageId);
621
+ this.#commandTasks.delete(task);
622
+ });
623
+ this.#commandTasks.add(task);
624
+ return task;
625
+ }
626
+
556
627
  async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
557
628
  if (artifacts.length === 0) {
558
629
  return { receipt: baseReceipt, failureNoticeVisible: false };
@@ -589,7 +660,11 @@ export class QqHarnessBridge {
589
660
  };
590
661
  }
591
662
 
592
- async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
663
+ async #process(message, key, {
664
+ alreadyRecorded = false,
665
+ preparedMessage,
666
+ batchSubmission = null,
667
+ } = {}) {
593
668
  if (this.#signal?.aborted) return;
594
669
  const messageId = nonEmptyString(message?.messageId);
595
670
  const sender = nonEmptyString(message?.senderId);
@@ -614,6 +689,7 @@ export class QqHarnessBridge {
614
689
  const hasImages = hasInboundImages(promptMessage);
615
690
  const hasFiles = hasInboundFiles(promptMessage);
616
691
  let stream = null;
692
+ let batchSettled = batchSubmission === null;
617
693
  try {
618
694
  if (!text && !hasImages && !hasFiles) {
619
695
  await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
@@ -710,6 +786,10 @@ export class QqHarnessBridge {
710
786
  files: promptMessage.files,
711
787
  },
712
788
  }));
789
+ if (batchSubmission) {
790
+ this.#batchInputs.complete(key, batchSubmission.token);
791
+ batchSettled = true;
792
+ }
713
793
  } finally {
714
794
  await Promise.allSettled([
715
795
  this.#cancelPendingInteraction(key),
@@ -771,6 +851,15 @@ export class QqHarnessBridge {
771
851
  this.#status.lastError = null;
772
852
  return delivery.receipt;
773
853
  } catch (error) {
854
+ let batchFailureMessage = null;
855
+ if (!batchSettled && batchSubmission) {
856
+ if (error?.code === 'turn-stopped') {
857
+ this.#batchInputs.complete(key, batchSubmission.token);
858
+ } else {
859
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
860
+ }
861
+ batchSettled = true;
862
+ }
774
863
  if (error?.code === 'turn-stopped') {
775
864
  try {
776
865
  stream?.cancel?.();
@@ -794,11 +883,12 @@ export class QqHarnessBridge {
794
883
  this.#status.lastError = error?.message ?? String(error);
795
884
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
796
885
  try {
886
+ const errorMessage = inboundFileUserMessage(error)
887
+ ?? imagePromptUserMessage(error)
888
+ ?? t('消息处理失败,请稍后重试。');
797
889
  await this.#bot.sendText(
798
890
  target,
799
- inboundFileUserMessage(error)
800
- ?? imagePromptUserMessage(error)
801
- ?? t('消息处理失败,请稍后重试。'),
891
+ batchFailureMessage ? `${errorMessage}\n\n${batchFailureMessage}` : errorMessage,
802
892
  );
803
893
  await this.#state.markSeen(messageId);
804
894
  } catch (sendError) {