@xmanrui/dsh-im 0.7.0 → 0.7.1

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