@xmanrui/dsh-im 0.7.0 → 0.7.2

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 (30) hide show
  1. package/lib/index.js +119 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +423 -8
  4. package/src/channels/dingtalk/harness-client.mjs +16 -366
  5. package/src/channels/discord/discord-api.mjs +1 -1
  6. package/src/channels/discord/discord-runtime.mjs +11 -1
  7. package/src/channels/discord/harness-client.mjs +10 -2
  8. package/src/channels/feishu/bridge.mjs +571 -50
  9. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  10. package/src/channels/feishu/harness-client.mjs +16 -335
  11. package/src/channels/qq/harness-client.mjs +10 -2
  12. package/src/channels/qq/qq-bridge.mjs +428 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-approval.mjs +472 -0
  15. package/src/channels/shared/harness-client.mjs +858 -0
  16. package/src/channels/shared/harness-question.mjs +85 -0
  17. package/src/channels/shared/text-harness-bridge.mjs +486 -24
  18. package/src/channels/slack/harness-client.mjs +10 -2
  19. package/src/channels/slack/slack-runtime.mjs +11 -1
  20. package/src/channels/telegram/harness-client.mjs +10 -2
  21. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  22. package/src/channels/wecom/harness-client.mjs +10 -2
  23. package/src/channels/wecom/wecom-bridge.mjs +434 -14
  24. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  25. package/src/channels/weixin/harness-client.mjs +16 -326
  26. package/src/channels/weixin/weixin-api.mjs +1 -1
  27. package/src/channels/weixin/weixin-bridge.mjs +451 -22
  28. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  29. package/src/channels/whatsapp/harness-client.mjs +10 -2
  30. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -3,9 +3,17 @@ import {
3
3
  splitWeixinText,
4
4
  weixinMessageId,
5
5
  } from './weixin-api.mjs';
6
+ import {
7
+ harnessAnswerForQuestion,
8
+ harnessQuestionText,
9
+ validHarnessQuestion,
10
+ } from '../shared/harness-question.mjs';
11
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
6
12
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
7
13
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
8
14
 
15
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
16
+
9
17
  const HELP_TEXT = [
10
18
  '微信已连接 DeepSeek Harness。',
11
19
  '',
@@ -23,6 +31,16 @@ function conversationKey(userId) {
23
31
  return `p2p:${userId}`;
24
32
  }
25
33
 
34
+ function nonEmptyString(value) {
35
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
36
+ }
37
+
38
+ function canClaimInteractionReply(message, pending) {
39
+ return pending.questions[pending.index]
40
+ && nonEmptyString(message?.from_user_id) === pending.actor
41
+ && nonEmptyString(extractWeixinText(message));
42
+ }
43
+
26
44
  export function createWeixinBridgeStatus() {
27
45
  return {
28
46
  messagesReceived: 0,
@@ -46,7 +64,13 @@ export class WeixinHarnessBridge {
46
64
  #logger;
47
65
  #replyTimeoutMs;
48
66
  #maxMessageChars;
67
+ #signal;
49
68
  #queues = new Map();
69
+ #pendingInteractions = new Map();
70
+ #interactionKeys = new Map();
71
+ #acceptedMessageIds = new Set();
72
+ #approvalTasks = new Set();
73
+ #approvals;
50
74
 
51
75
  constructor({
52
76
  api,
@@ -59,6 +83,7 @@ export class WeixinHarnessBridge {
59
83
  logger = console,
60
84
  replyTimeoutMs = 600_000,
61
85
  maxMessageChars = 4_000,
86
+ signal,
62
87
  }) {
63
88
  if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
64
89
  if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
@@ -73,6 +98,8 @@ export class WeixinHarnessBridge {
73
98
  this.#logger = logger;
74
99
  this.#replyTimeoutMs = replyTimeoutMs;
75
100
  this.#maxMessageChars = maxMessageChars;
101
+ this.#signal = signal;
102
+ this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
76
103
  }
77
104
 
78
105
  get status() {
@@ -80,31 +107,105 @@ export class WeixinHarnessBridge {
80
107
  }
81
108
 
82
109
  accept(message) {
83
- const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
84
- const previous = this.#queues.get(sender) ?? Promise.resolve();
110
+ if (this.#signal?.aborted) return Promise.resolve();
111
+ if (message?.message_type === 2) return Promise.resolve();
112
+ const messageId = weixinMessageId(message);
113
+ const sender = nonEmptyString(message?.from_user_id);
114
+ if (!messageId || !sender || this.#state.hasSeen(messageId)
115
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
116
+ this.#acceptedMessageIds.add(messageId);
117
+ const key = conversationKey(sender);
118
+ const contextToken = nonEmptyString(message?.context_token) ?? undefined;
119
+ const runId = nonEmptyString(message?.run_id) ?? undefined;
120
+ const pending = this.#pendingInteractions.get(key);
121
+ const approval = this.#approvals.claimReply({
122
+ key,
123
+ actor: sender,
124
+ messageId,
125
+ text: extractWeixinText(message),
126
+ addressed: true,
127
+ hasPendingQuestion: Boolean(pending),
128
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
129
+ ? pending.queue
130
+ : null,
131
+ isQuestionPending: () => this.#pendingInteractions.has(key),
132
+ send: (text) => this.#send(sender, text, contextToken, runId),
133
+ });
134
+ if (approval) {
135
+ let task;
136
+ task = approval.process(async () => {
137
+ if (this.#state.hasSeen(messageId)) return false;
138
+ await this.#state.markSeen(messageId);
139
+ this.#status.messagesReceived += 1;
140
+ this.#status.lastMessageAt = new Date().toISOString();
141
+ return true;
142
+ })
143
+ .finally(() => {
144
+ this.#acceptedMessageIds.delete(messageId);
145
+ this.#approvalTasks.delete(task);
146
+ });
147
+ this.#approvalTasks.add(task);
148
+ return task;
149
+ }
150
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
151
+ return this.#enqueueMessage(message, messageId, key);
152
+ }
153
+ if (pending) {
154
+ if (canClaimInteractionReply(message, pending)) {
155
+ pending.claimedReplyMessageId = messageId;
156
+ }
157
+ const previous = pending.queue ?? Promise.resolve();
158
+ const current = previous
159
+ .catch(() => undefined)
160
+ .then(() => this.#processInteractionReply(message, messageId, key, pending))
161
+ .catch((error) => this.#handleInteractionFailure(message, messageId, error))
162
+ .finally(() => {
163
+ this.#acceptedMessageIds.delete(messageId);
164
+ if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
165
+ if (pending.queue === current) pending.queue = null;
166
+ });
167
+ pending.queue = current;
168
+ return current;
169
+ }
170
+ return this.#enqueueMessage(message, messageId, key);
171
+ }
172
+
173
+ #enqueueMessage(message, messageId, key, {
174
+ releaseMessageId = true,
175
+ alreadyRecorded = false,
176
+ } = {}) {
177
+ const previous = this.#queues.get(key) ?? Promise.resolve();
85
178
  const current = previous
86
179
  .catch(() => undefined)
87
- .then(() => this.#process(message))
180
+ .then(() => this.#process(message, key, { alreadyRecorded }))
88
181
  .finally(() => {
89
- if (this.#queues.get(sender) === current) this.#queues.delete(sender);
182
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
183
+ if (this.#queues.get(key) === current) this.#queues.delete(key);
90
184
  });
91
- this.#queues.set(sender, current);
185
+ this.#queues.set(key, current);
92
186
  return current;
93
187
  }
94
188
 
95
189
  async waitForIdle() {
96
- await Promise.allSettled([...this.#queues.values()]);
190
+ await Promise.allSettled([
191
+ ...this.#queues.values(),
192
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
193
+ pending.queue ? [pending.queue] : []
194
+ )),
195
+ ...this.#approvalTasks,
196
+ ]);
97
197
  }
98
198
 
99
- async #process(message) {
100
- if (message?.message_type === 2) return;
199
+ async #process(message, key, { alreadyRecorded = false } = {}) {
200
+ this.#signal?.throwIfAborted();
101
201
  const messageId = weixinMessageId(message);
102
- const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
202
+ const sender = nonEmptyString(message?.from_user_id);
103
203
  if (!messageId || !sender) return;
104
- if (this.#state.hasSeen(messageId)) return;
105
-
106
- this.#status.messagesReceived += 1;
107
- this.#status.lastMessageAt = new Date().toISOString();
204
+ if (!alreadyRecorded) {
205
+ if (this.#state.hasSeen(messageId)) return;
206
+ this.#status.messagesReceived += 1;
207
+ this.#status.lastMessageAt = new Date().toISOString();
208
+ }
108
209
  if (sender !== this.#ownerUserId) {
109
210
  this.#status.messagesRejected += 1;
110
211
  this.#status.lastRejectedAt = new Date().toISOString();
@@ -122,14 +223,13 @@ export class WeixinHarnessBridge {
122
223
  }
123
224
 
124
225
  const command = text.trim().toLowerCase();
125
- const key = conversationKey(sender);
126
226
  if (command === '/help') {
127
227
  await this.#send(sender, HELP_TEXT, contextToken, runId);
128
228
  await this.#state.markSeen(messageId);
129
229
  return;
130
230
  }
131
231
  if (command === '/status') {
132
- await this.#harness.ensureRunning();
232
+ await this.#harness.ensureRunning({ signal: this.#signal });
133
233
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
134
234
  await this.#state.markSeen(messageId);
135
235
  return;
@@ -149,19 +249,40 @@ export class WeixinHarnessBridge {
149
249
  return;
150
250
  }
151
251
 
152
- const { answer } = await askInWorkspaceSession({
153
- harness: this.#harness,
154
- state: this.#state,
155
- key,
156
- text,
157
- askOptions: { timeoutMs: this.#replyTimeoutMs },
158
- });
252
+ let answer;
253
+ try {
254
+ ({ answer } = await askInWorkspaceSession({
255
+ harness: this.#harness,
256
+ state: this.#state,
257
+ key,
258
+ text,
259
+ createOptions: { signal: this.#signal },
260
+ existsOptions: { signal: this.#signal },
261
+ askOptions: {
262
+ timeoutMs: this.#replyTimeoutMs,
263
+ signal: this.#signal,
264
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
265
+ key,
266
+ actor: sender,
267
+ contextToken,
268
+ runId,
269
+ }),
270
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
271
+ },
272
+ }));
273
+ } finally {
274
+ await Promise.allSettled([
275
+ this.#cancelPendingInteraction(key),
276
+ this.#approvals.closeRoute(key),
277
+ ]);
278
+ }
159
279
  await this.#send(sender, answer, contextToken, runId);
160
280
  await this.#state.markSeen(messageId);
161
281
  this.#status.messagesReplied += 1;
162
282
  this.#status.lastReplyAt = new Date().toISOString();
163
283
  this.#status.lastError = null;
164
284
  } catch (error) {
285
+ if (this.#signal?.aborted) return;
165
286
  this.#status.lastError = error?.message ?? String(error);
166
287
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
167
288
  try {
@@ -173,6 +294,314 @@ export class WeixinHarnessBridge {
173
294
  }
174
295
  }
175
296
 
297
+ async #processInteractionReply(message, messageId, key, expected) {
298
+ this.#signal?.throwIfAborted();
299
+ const current = this.#pendingInteractions.get(key);
300
+ const claimed = expected.claimedReplyMessageId === messageId;
301
+ if (!current || current !== expected || current.submitting) {
302
+ if (claimed && (!current || current !== expected)) {
303
+ return this.#discardResolvedInteractionReply(message, messageId);
304
+ }
305
+ return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
306
+ }
307
+ if (this.#state.hasSeen(messageId)) return;
308
+ await this.#state.markSeen(messageId);
309
+ this.#status.messagesReceived += 1;
310
+ this.#status.lastMessageAt = new Date().toISOString();
311
+
312
+ const text = nonEmptyString(extractWeixinText(message));
313
+ const contextToken = nonEmptyString(message?.context_token) ?? undefined;
314
+ const runId = nonEmptyString(message?.run_id) ?? undefined;
315
+ if (!text) {
316
+ await this.#send(
317
+ expected.actor,
318
+ '请用文字回答当前问题。',
319
+ contextToken,
320
+ runId,
321
+ );
322
+ return;
323
+ }
324
+
325
+ const pending = this.#pendingInteractions.get(key);
326
+ if (!pending || pending !== expected || pending.submitting) {
327
+ if (claimed && (!pending || pending !== expected)) {
328
+ await this.#send(
329
+ expected.actor,
330
+ INTERACTION_RESOLVED_TEXT,
331
+ contextToken,
332
+ runId,
333
+ );
334
+ return;
335
+ }
336
+ return this.#enqueueMessage(message, messageId, key, {
337
+ releaseMessageId: false,
338
+ alreadyRecorded: true,
339
+ });
340
+ }
341
+ pending.contextToken = contextToken;
342
+ pending.runId = runId;
343
+ if (pending.needsPresentation) {
344
+ try {
345
+ await this.#presentInteraction(pending);
346
+ } catch {
347
+ this.#status.lastError = '微信交互问题发送失败。';
348
+ this.#logger.error?.('[dsh-weixin] failed to retry an interaction question');
349
+ pending.interaction.reconnect?.();
350
+ return;
351
+ }
352
+ const presentedPending = this.#pendingInteractions.get(key);
353
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
354
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
355
+ await this.#send(
356
+ expected.actor,
357
+ INTERACTION_RESOLVED_TEXT,
358
+ contextToken,
359
+ runId,
360
+ ).catch(() => undefined);
361
+ return;
362
+ }
363
+ return this.#enqueueMessage(message, messageId, key, {
364
+ releaseMessageId: false,
365
+ alreadyRecorded: true,
366
+ });
367
+ }
368
+ }
369
+
370
+ const question = pending.questions[pending.index];
371
+ if (!question) return;
372
+ pending.answers.push(harnessAnswerForQuestion(question, text));
373
+ pending.index += 1;
374
+ if (pending.index < pending.questions.length) {
375
+ if (pending.claimedReplyMessageId === messageId) {
376
+ pending.claimedReplyMessageId = null;
377
+ }
378
+ pending.needsPresentation = true;
379
+ try {
380
+ await this.#presentInteraction(pending);
381
+ } catch {
382
+ this.#status.lastError = '微信交互问题发送失败。';
383
+ this.#logger.error?.('[dsh-weixin] failed to send the next interaction question');
384
+ pending.interaction.reconnect?.();
385
+ }
386
+ return;
387
+ }
388
+
389
+ pending.submitting = true;
390
+ try {
391
+ await pending.interaction.respond({
392
+ ok: true,
393
+ value: {
394
+ sessionId: pending.sessionId,
395
+ answer: { answers: pending.answers },
396
+ },
397
+ });
398
+ this.#clearPendingInteraction(key, pending.interactionId);
399
+ this.#status.lastError = null;
400
+ } catch (error) {
401
+ if (this.#signal?.aborted) return;
402
+ if (error?.code === 'interaction-not-pending') {
403
+ this.#clearPendingInteraction(key, pending.interactionId);
404
+ await this.#send(
405
+ pending.actor,
406
+ INTERACTION_RESOLVED_TEXT,
407
+ pending.contextToken,
408
+ pending.runId,
409
+ ).catch(() => undefined);
410
+ return;
411
+ }
412
+ if (this.#pendingInteractions.get(key) !== pending) return;
413
+ pending.submitting = false;
414
+ pending.answers.pop();
415
+ pending.index -= 1;
416
+ this.#status.lastError = '回答提交失败。';
417
+ this.#logger.error?.('[dsh-weixin] failed to answer a Harness interaction');
418
+ await this.#send(
419
+ pending.actor,
420
+ '回答提交失败,请重新发送当前问题的答案。',
421
+ pending.contextToken,
422
+ pending.runId,
423
+ ).catch(() => undefined);
424
+ }
425
+ }
426
+
427
+ async #handleInteraction(interaction, {
428
+ key,
429
+ actor,
430
+ contextToken,
431
+ runId,
432
+ }) {
433
+ if (interaction?.kind === 'approval') {
434
+ return this.#approvals.handleRequested(interaction, {
435
+ key,
436
+ actor,
437
+ send: (text) => this.#send(actor, text, contextToken, runId),
438
+ });
439
+ }
440
+ if (interaction?.kind !== 'question') return;
441
+ const questions = interaction?.payload?.questions;
442
+ const interactionId = typeof interaction?.interactionId === 'string'
443
+ ? interaction.interactionId
444
+ : interaction?.rpcId;
445
+ if (typeof interaction?.rpcId !== 'string'
446
+ || typeof interactionId !== 'string'
447
+ || typeof interaction.sessionId !== 'string'
448
+ || !Array.isArray(questions)
449
+ || questions.length === 0
450
+ || questions.some((question) => !validHarnessQuestion(question))) {
451
+ this.#logger.warn?.('[dsh-weixin] ignored an invalid Harness question interaction');
452
+ return;
453
+ }
454
+
455
+ if (interaction.recovered === true) {
456
+ await interaction.respond({
457
+ ok: false,
458
+ error: {
459
+ code: 'cancelled',
460
+ message: 'Weixin safely cancelled an interaction left by an earlier client.',
461
+ details: {},
462
+ },
463
+ });
464
+ await this.#send(
465
+ actor,
466
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
467
+ contextToken,
468
+ runId,
469
+ ).catch(() => undefined);
470
+ return;
471
+ }
472
+
473
+ const existing = this.#pendingInteractions.get(key);
474
+ if (existing?.interactionId === interactionId) {
475
+ existing.interaction = interaction;
476
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
477
+ return;
478
+ }
479
+ if (this.#interactionKeys.has(interactionId)) return;
480
+ if (existing) {
481
+ await interaction.respond({
482
+ ok: false,
483
+ error: {
484
+ code: 'cancelled',
485
+ message: 'Weixin is already handling another user interaction.',
486
+ details: {},
487
+ },
488
+ });
489
+ return;
490
+ }
491
+
492
+ const pending = {
493
+ kind: 'question',
494
+ interactionId,
495
+ sessionId: interaction.sessionId,
496
+ interaction,
497
+ actor,
498
+ questions,
499
+ answers: [],
500
+ index: 0,
501
+ contextToken,
502
+ runId,
503
+ queue: null,
504
+ claimedReplyMessageId: null,
505
+ presentationPromise: null,
506
+ submitting: false,
507
+ needsPresentation: true,
508
+ };
509
+ this.#pendingInteractions.set(key, pending);
510
+ this.#interactionKeys.set(interactionId, key);
511
+ await this.#presentInteraction(pending);
512
+ }
513
+
514
+ async #handleInteractionResolved(resolution) {
515
+ if (resolution?.kind === 'approval') {
516
+ await this.#approvals.handleResolved(resolution);
517
+ return;
518
+ }
519
+ const interactionId = resolution?.interactionId;
520
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
521
+ const key = this.#interactionKeys.get(interactionId);
522
+ if (!key) return;
523
+ this.#clearPendingInteraction(key, interactionId);
524
+ }
525
+
526
+ #presentInteraction(pending) {
527
+ if (!pending.needsPresentation) return Promise.resolve();
528
+ if (pending.presentationPromise) return pending.presentationPromise;
529
+ const question = pending.questions[pending.index];
530
+ if (!question) return Promise.resolve();
531
+ const presentation = this.#send(
532
+ pending.actor,
533
+ harnessQuestionText(question, pending.index, pending.questions.length),
534
+ pending.contextToken,
535
+ pending.runId,
536
+ ).then(() => {
537
+ pending.needsPresentation = false;
538
+ }).finally(() => {
539
+ if (pending.presentationPromise === presentation) pending.presentationPromise = null;
540
+ });
541
+ pending.presentationPromise = presentation;
542
+ return presentation;
543
+ }
544
+
545
+ async #discardResolvedInteractionReply(message, messageId) {
546
+ if (this.#state.hasSeen(messageId)) return;
547
+ await this.#state.markSeen(messageId);
548
+ this.#status.messagesReceived += 1;
549
+ this.#status.lastMessageAt = new Date().toISOString();
550
+ await this.#send(
551
+ nonEmptyString(message?.from_user_id),
552
+ INTERACTION_RESOLVED_TEXT,
553
+ nonEmptyString(message?.context_token) ?? undefined,
554
+ nonEmptyString(message?.run_id) ?? undefined,
555
+ ).catch(() => undefined);
556
+ }
557
+
558
+ #takePendingInteraction(key, interactionId) {
559
+ const pending = this.#pendingInteractions.get(key);
560
+ if (!pending
561
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
562
+ this.#pendingInteractions.delete(key);
563
+ this.#interactionKeys.delete(pending.interactionId);
564
+ return pending;
565
+ }
566
+
567
+ #clearPendingInteraction(key, interactionId) {
568
+ return this.#takePendingInteraction(key, interactionId) !== null;
569
+ }
570
+
571
+ async #cancelPendingInteraction(key) {
572
+ const pending = this.#takePendingInteraction(key);
573
+ if (!pending || pending.kind !== 'question') return;
574
+ try {
575
+ await pending.interaction.respond({
576
+ ok: false,
577
+ error: {
578
+ code: 'cancelled',
579
+ message: 'The Weixin interaction ended before the user answered.',
580
+ details: {},
581
+ },
582
+ }, { signal: AbortSignal.timeout(5_000) });
583
+ } catch (error) {
584
+ if (error?.code !== 'interaction-not-pending') {
585
+ this.#logger.warn?.('[dsh-weixin] failed to cancel a pending Harness interaction');
586
+ }
587
+ }
588
+ }
589
+
590
+ async #handleInteractionFailure(message, messageId, error) {
591
+ if (this.#signal?.aborted) return;
592
+ this.#status.lastError = error?.message ?? String(error);
593
+ this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
594
+ if (!this.#state.hasSeen(messageId)) {
595
+ await this.#state.markSeen(messageId).catch(() => undefined);
596
+ }
597
+ await this.#send(
598
+ nonEmptyString(message?.from_user_id),
599
+ '消息处理失败,请稍后重试。',
600
+ nonEmptyString(message?.context_token) ?? undefined,
601
+ nonEmptyString(message?.run_id) ?? undefined,
602
+ ).catch(() => undefined);
603
+ }
604
+
176
605
  async #send(toUserId, text, contextToken, runId) {
177
606
  for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
178
607
  await this.#api.sendText({
@@ -1,6 +1,26 @@
1
1
  import { WeixinApiError } from './weixin-api.mjs';
2
2
  import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
3
3
 
4
+ const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
5
+
6
+ function startRetryDelays(value) {
7
+ if (value === undefined) return [...DEFAULT_START_RETRY_DELAYS_MS];
8
+ if (!Array.isArray(value)) throw new TypeError('startRetryDelaysMs must be an array');
9
+ return value.map((wait) => {
10
+ if (!Number.isFinite(wait) || wait < 0) {
11
+ throw new TypeError('startRetryDelaysMs must contain non-negative delays');
12
+ }
13
+ return wait;
14
+ });
15
+ }
16
+
17
+ function retryableStartError(error) {
18
+ if (!(error instanceof WeixinApiError)) return false;
19
+ if (error.code === 'network-error' || error.code === 'timeout') return true;
20
+ return error.code === 'http-error'
21
+ && (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500);
22
+ }
23
+
4
24
  function delay(ms, signal) {
5
25
  return new Promise((resolve, reject) => {
6
26
  if (signal?.aborted) {
@@ -42,6 +62,7 @@ export class WeixinRuntime {
42
62
  #logger;
43
63
  #replyTimeoutMs;
44
64
  #maxMessageChars;
65
+ #startRetryDelaysMs;
45
66
  #status = createWeixinRuntimeStatus();
46
67
  #bridge = null;
47
68
  #abortController = null;
@@ -57,6 +78,7 @@ export class WeixinRuntime {
57
78
  logger = console,
58
79
  replyTimeoutMs = 600_000,
59
80
  maxMessageChars = 4_000,
81
+ startRetryDelaysMs,
60
82
  }) {
61
83
  if (!api || !config || !token || !harness || !state) {
62
84
  throw new TypeError('WeixinRuntime requires API, account, token, Harness, and state');
@@ -69,6 +91,7 @@ export class WeixinRuntime {
69
91
  this.#logger = logger;
70
92
  this.#replyTimeoutMs = replyTimeoutMs;
71
93
  this.#maxMessageChars = maxMessageChars;
94
+ this.#startRetryDelaysMs = startRetryDelays(startRetryDelaysMs);
72
95
  }
73
96
 
74
97
  get status() {
@@ -92,10 +115,9 @@ export class WeixinRuntime {
92
115
  try {
93
116
  await this.#harness.ensureRunning();
94
117
  this.#status.harnessReachable = true;
95
- await this.#api.notifyStart({
96
- baseUrl: this.#config.baseUrl,
97
- token: this.#token,
98
- });
118
+ await this.#notifyStart();
119
+ this.#abortController = new AbortController();
120
+ const signal = this.#abortController.signal;
99
121
  this.#bridge = new WeixinHarnessBridge({
100
122
  api: this.#api,
101
123
  baseUrl: this.#config.baseUrl,
@@ -107,12 +129,11 @@ export class WeixinRuntime {
107
129
  logger: this.#logger,
108
130
  replyTimeoutMs: this.#replyTimeoutMs,
109
131
  maxMessageChars: this.#maxMessageChars,
132
+ signal,
110
133
  });
111
- this.#abortController = new AbortController();
112
134
  this.#status.ready = true;
113
135
  this.#status.weixinConnectionState = 'connected';
114
136
  this.#status.lastCheckedAt = Date.now();
115
- const signal = this.#abortController.signal;
116
137
  this.#monitor = this.#runMonitor(signal).catch((error) => {
117
138
  if (signal.aborted) return;
118
139
  this.#status.ready = false;
@@ -122,6 +143,9 @@ export class WeixinRuntime {
122
143
  });
123
144
  return this.status;
124
145
  } catch (error) {
146
+ this.#abortController?.abort();
147
+ this.#abortController = null;
148
+ this.#bridge = null;
125
149
  this.#status.ready = false;
126
150
  this.#status.weixinConnectionState = 'failed';
127
151
  this.#status.lastError = error?.message ?? String(error);
@@ -129,6 +153,25 @@ export class WeixinRuntime {
129
153
  }
130
154
  }
131
155
 
156
+ async #notifyStart() {
157
+ for (let attempt = 0; ; attempt += 1) {
158
+ try {
159
+ return await this.#api.notifyStart({
160
+ baseUrl: this.#config.baseUrl,
161
+ token: this.#token,
162
+ });
163
+ } catch (error) {
164
+ const wait = this.#startRetryDelaysMs[attempt];
165
+ if (wait === undefined || !retryableStartError(error)) throw error;
166
+ this.#logger.warn?.(
167
+ `[dsh-weixin] account ${this.#config.botId} start request failed; retrying in ${wait}ms:`,
168
+ error,
169
+ );
170
+ await delay(wait);
171
+ }
172
+ }
173
+ }
174
+
132
175
  async #runMonitor(signal) {
133
176
  let consecutiveFailures = 0;
134
177
  while (!signal.aborted) {
@@ -156,7 +199,13 @@ export class WeixinRuntime {
156
199
  this.#status.lastError = null;
157
200
 
158
201
  for (const message of response?.msgs ?? []) {
159
- await this.#bridge.accept(message);
202
+ void this.#bridge.accept(message).catch((error) => {
203
+ if (signal.aborted) return;
204
+ this.#logger.error?.(
205
+ `[dsh-weixin] account ${this.#config.botId} message handling failed:`,
206
+ error,
207
+ );
208
+ });
160
209
  }
161
210
  if (typeof response?.get_updates_buf === 'string' && response.get_updates_buf) {
162
211
  await this.#state.setGetUpdatesBuf(response.get_updates_buf);
@@ -1,3 +1,11 @@
1
- import { HarnessClient } from '../weixin/harness-client.mjs';
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
2
 
3
- export class WhatsappHarnessClient extends HarnessClient {}
3
+ export class WhatsappHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'whatsapp',
8
+ logPrefix: 'dsh-whatsapp',
9
+ });
10
+ }
11
+ }