@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.
@@ -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,6 +69,9 @@ 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 检查连接状态'),
67
76
  t('/help 显示本帮助'),
68
77
  ].join('\n');
@@ -254,6 +263,10 @@ function interactionReplyText(frame) {
254
263
  return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
255
264
  }
256
265
 
266
+ function isNativeWecomText(frame) {
267
+ return bodyOf(frame).msgtype === 'text' && Boolean(nonEmptyString(messageText(frame)));
268
+ }
269
+
257
270
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
258
271
  const source = String(text ?? '').trim();
259
272
  if (!source) return [];
@@ -466,6 +479,7 @@ export class WecomHarnessBridge {
466
479
  #approvalTasks = new Set();
467
480
  #commandTasks = new Set();
468
481
  #approvals;
482
+ #batchInputs = new BatchInputManager();
469
483
  #prefetchedImageCount = 0;
470
484
 
471
485
  constructor({
@@ -523,6 +537,40 @@ export class WecomHarnessBridge {
523
537
  const pending = this.#pendingInteractions.get(key);
524
538
  const commandMessage = wecomInboundMessage(frame, this.#client);
525
539
  const commandText = nonEmptyString(commandMessage.content) ?? '';
540
+ const batchCommand = isBatchInputCommand(commandText);
541
+ const batchStatus = this.#batchInputs.status(key);
542
+ if (batchCommand && body.chattype === 'group') {
543
+ return this.#finishBatchResult(
544
+ frame,
545
+ messageId,
546
+ chatId,
547
+ { message: batchInputGroupUnsupportedMessage() },
548
+ );
549
+ }
550
+ if (body.chattype === 'single'
551
+ && (batchCommand || batchStatus.phase === 'collecting')) {
552
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
553
+ const result = exactBatchStart
554
+ && batchStatus.phase === 'idle'
555
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
556
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
557
+ : this.#batchInputs.handle(key, commandText, {
558
+ plainText: isNativeWecomText(frame),
559
+ });
560
+ if (result.handled) {
561
+ if (result.kind === 'submit') {
562
+ return this.#enqueueMessage({
563
+ ...frame,
564
+ body: {
565
+ ...body,
566
+ msgtype: 'text',
567
+ text: { content: result.prompt },
568
+ },
569
+ }, messageId, key, { batchSubmission: result });
570
+ }
571
+ return this.#finishBatchResult(frame, messageId, chatId, result);
572
+ }
573
+ }
526
574
  const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
527
575
  ? runControlCommand
528
576
  : (isModelCommand(commandText)
@@ -616,6 +664,7 @@ export class WecomHarnessBridge {
616
664
  #enqueueMessage(frame, messageId, key, {
617
665
  releaseMessageId = true,
618
666
  alreadyRecorded = false,
667
+ batchSubmission = null,
619
668
  } = {}) {
620
669
  // WeCom image URLs expire after five minutes, while a conversation turn
621
670
  // may legally stay queued longer. Start the authenticated SDK download as
@@ -637,7 +686,11 @@ export class WecomHarnessBridge {
637
686
  const previous = this.#queues.get(key) ?? Promise.resolve();
638
687
  const current = previous
639
688
  .catch(() => undefined)
640
- .then(() => this.#process(frame, { alreadyRecorded, preparedMessage }))
689
+ .then(() => this.#process(frame, {
690
+ alreadyRecorded,
691
+ preparedMessage,
692
+ batchSubmission,
693
+ }))
641
694
  .finally(() => {
642
695
  this.#prefetchedImageCount -= reservedImages;
643
696
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -647,6 +700,29 @@ export class WecomHarnessBridge {
647
700
  return current;
648
701
  }
649
702
 
703
+ #finishBatchResult(frame, messageId, chatId, result) {
704
+ let task;
705
+ task = Promise.resolve().then(async () => {
706
+ if (this.#state.hasSeen(messageId)) return;
707
+ await this.#state.markSeen(messageId);
708
+ this.#status.messagesReceived += 1;
709
+ this.#status.lastMessageAt = new Date().toISOString();
710
+ if (result.message) await this.#sendImmediate(frame, chatId, result.message);
711
+ this.#status.lastError = null;
712
+ }).catch(async (error) => {
713
+ if (this.#signal?.aborted) return;
714
+ this.#status.lastError = error?.message ?? String(error);
715
+ this.#logger.error?.('[dsh-im:wecom] failed to process a batch input message');
716
+ await this.#sendImmediate(frame, chatId, t('消息处理失败,请稍后重试。'))
717
+ .catch(() => undefined);
718
+ }).finally(() => {
719
+ this.#acceptedMessageIds.delete(messageId);
720
+ this.#commandTasks.delete(task);
721
+ });
722
+ this.#commandTasks.add(task);
723
+ return task;
724
+ }
725
+
650
726
  async waitForIdle() {
651
727
  await Promise.allSettled([
652
728
  ...this.#queues.values(),
@@ -748,7 +824,11 @@ export class WecomHarnessBridge {
748
824
  };
749
825
  }
750
826
 
751
- async #process(frame, { alreadyRecorded = false, preparedMessage } = {}) {
827
+ async #process(frame, {
828
+ alreadyRecorded = false,
829
+ preparedMessage,
830
+ batchSubmission = null,
831
+ } = {}) {
752
832
  if (this.#signal?.aborted) return;
753
833
  const body = bodyOf(frame);
754
834
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
@@ -767,6 +847,7 @@ export class WecomHarnessBridge {
767
847
  const key = conversationKey(frame);
768
848
  let streamId = null;
769
849
  let streamStarted = false;
850
+ let batchSettled = batchSubmission === null;
770
851
  try {
771
852
  if (!text && !hasImages && !hasFiles) {
772
853
  await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
@@ -855,6 +936,10 @@ export class WecomHarnessBridge {
855
936
  files: message.files,
856
937
  },
857
938
  });
939
+ if (batchSubmission) {
940
+ this.#batchInputs.complete(key, batchSubmission.token);
941
+ batchSettled = true;
942
+ }
858
943
 
859
944
  this.#signal?.throwIfAborted();
860
945
  const displayAnswer = answerTextForDelivery(answer, artifacts);
@@ -915,6 +1000,15 @@ export class WecomHarnessBridge {
915
1000
  this.#status.lastError = null;
916
1001
  return delivery.receipt;
917
1002
  } catch (error) {
1003
+ let batchFailureMessage = null;
1004
+ if (!batchSettled && batchSubmission) {
1005
+ if (error?.code === 'turn-stopped') {
1006
+ this.#batchInputs.complete(key, batchSubmission.token);
1007
+ } else {
1008
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
1009
+ }
1010
+ batchSettled = true;
1011
+ }
918
1012
  if (error?.code === 'turn-stopped') {
919
1013
  if (streamStarted && streamId) {
920
1014
  await this.#client.replyStream(frame, streamId, t('已停止。'), true)
@@ -929,11 +1023,14 @@ export class WecomHarnessBridge {
929
1023
  const errorText = inboundFileUserMessage(error)
930
1024
  ?? imagePromptUserMessage(error)
931
1025
  ?? t('消息处理失败,请稍后重试。');
1026
+ const visibleError = batchFailureMessage
1027
+ ? `${errorText}\n\n${batchFailureMessage}`
1028
+ : errorText;
932
1029
  try {
933
1030
  if (streamStarted && streamId) {
934
- await this.#client.replyStream(frame, streamId, errorText, true);
1031
+ await this.#client.replyStream(frame, streamId, visibleError, true);
935
1032
  } else {
936
- await this.#sendImmediate(frame, chatId, errorText);
1033
+ await this.#sendImmediate(frame, chatId, visibleError);
937
1034
  }
938
1035
  await this.#state.markSeen(messageId);
939
1036
  } 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,6 +75,9 @@ 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 检查连接状态'),
74
82
  t('/help 显示本帮助'),
75
83
  ].join('\n');
@@ -104,6 +112,15 @@ function hasWeixinFileItems(message) {
104
112
  && message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
105
113
  }
106
114
 
115
+ function isNativeWeixinText(message) {
116
+ return Array.isArray(message?.item_list)
117
+ && message.item_list.length > 0
118
+ && message.item_list.every((item) => (
119
+ item?.type === 1 && typeof item.text_item?.text === 'string'
120
+ ))
121
+ && Boolean(nonEmptyString(extractWeixinText(message)));
122
+ }
123
+
107
124
  function canClaimInteractionReply(message, pending) {
108
125
  return pending.questions[pending.index]
109
126
  && nonEmptyString(message?.from_user_id) === pending.actor
@@ -176,6 +193,7 @@ export class WeixinHarnessBridge {
176
193
  #approvalTasks = new Set();
177
194
  #commandTasks = new Set();
178
195
  #approvals;
196
+ #batchInputs = new BatchInputManager();
179
197
 
180
198
  constructor({
181
199
  api,
@@ -227,6 +245,35 @@ export class WeixinHarnessBridge {
227
245
  const runId = nonEmptyString(message?.run_id) ?? undefined;
228
246
  const pending = this.#pendingInteractions.get(key);
229
247
  const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
248
+ const batchCommand = isBatchInputCommand(commandText);
249
+ const batchStatus = this.#batchInputs.status(key);
250
+ if (sender === this.#ownerUserId
251
+ && (batchCommand || batchStatus.phase === 'collecting')) {
252
+ const exactBatchStart = /^\/batch$/iu.test(commandText);
253
+ const result = exactBatchStart
254
+ && batchStatus.phase === 'idle'
255
+ && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
256
+ ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
257
+ : this.#batchInputs.handle(key, commandText, {
258
+ plainText: isNativeWeixinText(message),
259
+ });
260
+ if (result.handled) {
261
+ if (result.kind === 'submit') {
262
+ return this.#enqueueMessage({
263
+ ...message,
264
+ item_list: [{ type: 1, text_item: { text: result.prompt } }],
265
+ }, messageId, key, { batchSubmission: result });
266
+ }
267
+ return this.#finishBatchResult(
268
+ message,
269
+ messageId,
270
+ sender,
271
+ contextToken,
272
+ runId,
273
+ result,
274
+ );
275
+ }
276
+ }
230
277
  const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
231
278
  ? runControlCommand
232
279
  : (isModelCommand(commandText)
@@ -314,6 +361,7 @@ export class WeixinHarnessBridge {
314
361
  #enqueueMessage(message, messageId, key, {
315
362
  releaseMessageId = true,
316
363
  alreadyRecorded = false,
364
+ batchSubmission = null,
317
365
  } = {}) {
318
366
  const preparedMessage = message.from_user_id === this.#ownerUserId
319
367
  ? prefetchInboundFiles(
@@ -324,7 +372,11 @@ export class WeixinHarnessBridge {
324
372
  const previous = this.#queues.get(key) ?? Promise.resolve();
325
373
  const current = previous
326
374
  .catch(() => undefined)
327
- .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
375
+ .then(() => this.#process(message, key, {
376
+ alreadyRecorded,
377
+ preparedMessage,
378
+ batchSubmission,
379
+ }))
328
380
  .finally(() => {
329
381
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
330
382
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -333,6 +385,33 @@ export class WeixinHarnessBridge {
333
385
  return current;
334
386
  }
335
387
 
388
+ #finishBatchResult(message, messageId, sender, contextToken, runId, result) {
389
+ let task;
390
+ task = Promise.resolve().then(async () => {
391
+ if (this.#state.hasSeen(messageId)) return;
392
+ await this.#state.markSeen(messageId);
393
+ this.#status.messagesReceived += 1;
394
+ this.#status.lastMessageAt = new Date().toISOString();
395
+ if (result.message) {
396
+ await this.#send(sender, result.message, contextToken, runId);
397
+ }
398
+ this.#status.lastError = null;
399
+ this.#status.lastMessageError = null;
400
+ }).catch(async (error) => {
401
+ if (this.#signal?.aborted) return;
402
+ this.#status.lastError = error?.message ?? String(error);
403
+ this.#status.lastMessageError = safeMessageError(error);
404
+ this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
405
+ await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
406
+ .catch(() => undefined);
407
+ }).finally(() => {
408
+ this.#acceptedMessageIds.delete(messageId);
409
+ this.#commandTasks.delete(task);
410
+ });
411
+ this.#commandTasks.add(task);
412
+ return task;
413
+ }
414
+
336
415
  async waitForIdle() {
337
416
  await Promise.allSettled([
338
417
  ...this.#queues.values(),
@@ -380,7 +459,11 @@ export class WeixinHarnessBridge {
380
459
  this.#status.lastMessageError = null;
381
460
  }
382
461
 
383
- async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
462
+ async #process(message, key, {
463
+ alreadyRecorded = false,
464
+ preparedMessage,
465
+ batchSubmission = null,
466
+ } = {}) {
384
467
  this.#signal?.throwIfAborted();
385
468
  const messageId = weixinMessageId(message);
386
469
  const sender = nonEmptyString(message?.from_user_id);
@@ -398,6 +481,7 @@ export class WeixinHarnessBridge {
398
481
 
399
482
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
400
483
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
484
+ let batchSettled = batchSubmission === null;
401
485
  try {
402
486
  const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
403
487
  const text = promptMessage.content;
@@ -479,6 +563,10 @@ export class WeixinHarnessBridge {
479
563
  files: promptMessage.files,
480
564
  },
481
565
  }));
566
+ if (batchSubmission) {
567
+ this.#batchInputs.complete(key, batchSubmission.token);
568
+ batchSettled = true;
569
+ }
482
570
  } finally {
483
571
  await Promise.allSettled([
484
572
  this.#cancelPendingInteraction(key),
@@ -515,6 +603,15 @@ export class WeixinHarnessBridge {
515
603
  this.#status.lastMessageError = null;
516
604
  return delivery.receipt;
517
605
  } catch (error) {
606
+ let batchFailureMessage = null;
607
+ if (!batchSettled && batchSubmission) {
608
+ if (error?.code === 'turn-stopped') {
609
+ this.#batchInputs.complete(key, batchSubmission.token);
610
+ } else {
611
+ batchFailureMessage = this.#batchInputs.fail(key, batchSubmission.token).message ?? null;
612
+ }
613
+ batchSettled = true;
614
+ }
518
615
  if (error?.code === 'turn-stopped') {
519
616
  await this.#state.markSeen(messageId);
520
617
  return;
@@ -529,7 +626,7 @@ export class WeixinHarnessBridge {
529
626
  try {
530
627
  await this.#send(
531
628
  sender,
532
- userMessage,
629
+ batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
533
630
  contextToken,
534
631
  runId,
535
632
  );
@@ -73,6 +73,23 @@ function delay(ms, signal) {
73
73
  });
74
74
  }
75
75
 
76
+ export function orderWeixinMessages(messages) {
77
+ if (!Array.isArray(messages) || messages.length < 2) return Array.isArray(messages) ? messages : [];
78
+ const orderField = ['seq', 'create_time_ms'].find((field) => messages.every((message) => (
79
+ (typeof message?.[field] === 'number' && Number.isFinite(message[field]))
80
+ || (typeof message?.[field] === 'string'
81
+ && message[field].trim()
82
+ && Number.isFinite(Number(message[field])))
83
+ )));
84
+ if (!orderField) return messages;
85
+ return messages
86
+ .map((message, index) => ({ message, index, order: Number(message[orderField]) }))
87
+ .sort((left, right) => (
88
+ left.order - right.order || left.index - right.index
89
+ ))
90
+ .map(({ message }) => message);
91
+ }
92
+
76
93
  export function createWeixinRuntimeStatus() {
77
94
  return {
78
95
  startedAt: null,
@@ -234,7 +251,7 @@ export class WeixinRuntime {
234
251
  this.#status.lastCheckedAt = Date.now();
235
252
  this.#status.lastError = null;
236
253
 
237
- for (const message of response?.msgs ?? []) {
254
+ for (const message of orderWeixinMessages(response?.msgs)) {
238
255
  void this.#bridge.accept(message).catch((error) => {
239
256
  if (signal.aborted) return;
240
257
  this.#logger.error?.(
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
 
3
3
  import {
4
4
  areJidsSameUser,
@@ -217,9 +217,9 @@ export function normalizeWhatsappMessage(message, accountJid, {
217
217
  const fromMe = message.key.fromMe === true;
218
218
  const selfChat = fromMe && !group
219
219
  && [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
220
- if (fromMe && !selfChat) return null;
221
- const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
222
- const senderAlternateJid = group ? message.key.participantAlt : alternateRemoteJid;
220
+ if (fromMe && !selfChat && !group) return null;
221
+ const senderJid = fromMe ? accountJid : group ? message.key.participant : remoteJid;
222
+ const senderAlternateJid = group && !fromMe ? message.key.participantAlt : alternateRemoteJid;
223
223
  if (typeof senderJid !== 'string' || !senderJid) return null;
224
224
  const viewOnce = hasViewOnceWrapper(message.message);
225
225
  const content = normalizeMessageContent(message.message);
@@ -239,9 +239,11 @@ export function normalizeWhatsappMessage(message, accountJid, {
239
239
  kind: group ? 'group' : 'direct',
240
240
  conversationId: remoteJid,
241
241
  content: messageText(content),
242
+ plainText: typeof content?.conversation === 'string'
243
+ || typeof content?.extendedTextMessage?.text === 'string',
242
244
  images: image ? [image] : [],
243
245
  files: file ? [file] : [],
244
- addressed: !group || mentioned || replyToSelf,
246
+ addressed: !group || fromMe || mentioned || replyToSelf,
245
247
  selfChat,
246
248
  replyTarget: { jid: remoteJid, quoted: message, selfChat },
247
249
  };
@@ -272,6 +274,14 @@ class RecentWhatsappOutboundIds {
272
274
  }
273
275
 
274
276
  remember(id) {
277
+ this.#store(id);
278
+ }
279
+
280
+ reserve(id) {
281
+ this.#store(id);
282
+ }
283
+
284
+ #store(id) {
275
285
  if (typeof id !== 'string' || !id) return;
276
286
  this.#purge();
277
287
  this.#ids.set(id, Date.now() + 5 * 60_000);
@@ -360,10 +370,23 @@ export class WhatsappBotClient {
360
370
  await this.#stopTyping(target.jid);
361
371
  const providerMessageIds = [];
362
372
  for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
373
+ const messageId = randomBytes(10).toString('hex').toUpperCase();
374
+ const options = {
375
+ ...(index === 0 && target.quoted ? { quoted: target.quoted } : {}),
376
+ messageId,
377
+ };
378
+ // Linked-account group messages are valid inbound prompts in open mode.
379
+ // Reserve our own id before dispatch so an early local echo cannot loop
380
+ // back through the bridge as another owner-authored group prompt.
381
+ if (typeof this.#outboundIds.reserve === 'function') {
382
+ this.#outboundIds.reserve(messageId);
383
+ } else {
384
+ this.#outboundIds.remember(messageId);
385
+ }
363
386
  const result = await this.#socket.sendMessage(
364
387
  target.jid,
365
388
  { text: chunk },
366
- index === 0 && target.quoted ? { quoted: target.quoted } : undefined,
389
+ options,
367
390
  );
368
391
  this.#outboundIds.remember(result?.key?.id);
369
392
  if (typeof result?.key?.id === 'string' && result.key.id) {