@xmanrui/dsh-im 0.6.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 (42) hide show
  1. package/README.md +22 -4
  2. package/lib/client.js +649 -357
  3. package/lib/index.js +120 -111
  4. package/package.json +1 -1
  5. package/plugin-src/client/build.mjs +1 -1
  6. package/plugin-src/client/i18n.js +14 -0
  7. package/plugin-src/client/index.js +11 -3
  8. package/plugin-src/client/styles.js +49 -8
  9. package/plugin-src/client/workspace-directory-picker.js +230 -0
  10. package/plugin-src/client/workspace-editor.js +38 -65
  11. package/scripts/verify-package.mjs +5 -0
  12. package/src/channels/dingtalk/dingtalk-bridge.mjs +363 -9
  13. package/src/channels/dingtalk/harness-client.mjs +16 -310
  14. package/src/channels/discord/discord-api.mjs +1 -1
  15. package/src/channels/discord/discord-runtime.mjs +11 -1
  16. package/src/channels/discord/harness-client.mjs +10 -2
  17. package/src/channels/feishu/bridge.mjs +525 -51
  18. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  19. package/src/channels/feishu/harness-client.mjs +16 -279
  20. package/src/channels/qq/harness-client.mjs +10 -2
  21. package/src/channels/qq/qq-bridge.mjs +383 -29
  22. package/src/channels/qq/qq-runtime.mjs +14 -3
  23. package/src/channels/shared/bot-workspace-store.mjs +185 -4
  24. package/src/channels/shared/harness-client.mjs +825 -0
  25. package/src/channels/shared/harness-question.mjs +85 -0
  26. package/src/channels/shared/harness-session-binding.mjs +110 -0
  27. package/src/channels/shared/text-harness-bridge.mjs +439 -25
  28. package/src/channels/shared/workspace-command.mjs +212 -16
  29. package/src/channels/shared/workspace-session.mjs +22 -9
  30. package/src/channels/slack/harness-client.mjs +10 -2
  31. package/src/channels/slack/slack-runtime.mjs +11 -1
  32. package/src/channels/telegram/harness-client.mjs +10 -2
  33. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  34. package/src/channels/wecom/harness-client.mjs +10 -2
  35. package/src/channels/wecom/wecom-bridge.mjs +389 -15
  36. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  37. package/src/channels/weixin/harness-client.mjs +16 -270
  38. package/src/channels/weixin/weixin-api.mjs +1 -1
  39. package/src/channels/weixin/weixin-bridge.mjs +406 -24
  40. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  41. package/src/channels/whatsapp/harness-client.mjs +10 -2
  42. 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
  '',
@@ -13,6 +20,8 @@ const HELP_TEXT = [
13
20
  '/new 开启一个全新会话',
14
21
  '/workspace 工作区绝对路径 切换工作区',
15
22
  '/workspacelist 列出工作区绝对路径',
23
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
24
+ '/session Session ID 将当前聊天绑定到指定会话',
16
25
  '/status 检查连接状态',
17
26
  '/help 显示本帮助',
18
27
  ].join('\n');
@@ -21,6 +30,16 @@ function conversationKey(userId) {
21
30
  return `p2p:${userId}`;
22
31
  }
23
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
+
24
43
  export function createWeixinBridgeStatus() {
25
44
  return {
26
45
  messagesReceived: 0,
@@ -44,7 +63,11 @@ export class WeixinHarnessBridge {
44
63
  #logger;
45
64
  #replyTimeoutMs;
46
65
  #maxMessageChars;
66
+ #signal;
47
67
  #queues = new Map();
68
+ #pendingInteractions = new Map();
69
+ #interactionKeys = new Map();
70
+ #acceptedMessageIds = new Set();
48
71
 
49
72
  constructor({
50
73
  api,
@@ -57,6 +80,7 @@ export class WeixinHarnessBridge {
57
80
  logger = console,
58
81
  replyTimeoutMs = 600_000,
59
82
  maxMessageChars = 4_000,
83
+ signal,
60
84
  }) {
61
85
  if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
62
86
  if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
@@ -71,6 +95,7 @@ export class WeixinHarnessBridge {
71
95
  this.#logger = logger;
72
96
  this.#replyTimeoutMs = replyTimeoutMs;
73
97
  this.#maxMessageChars = maxMessageChars;
98
+ this.#signal = signal;
74
99
  }
75
100
 
76
101
  get status() {
@@ -78,31 +103,73 @@ export class WeixinHarnessBridge {
78
103
  }
79
104
 
80
105
  accept(message) {
81
- const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
82
- 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();
83
143
  const current = previous
84
144
  .catch(() => undefined)
85
- .then(() => this.#process(message))
145
+ .then(() => this.#process(message, key, { alreadyRecorded }))
86
146
  .finally(() => {
87
- 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);
88
149
  });
89
- this.#queues.set(sender, current);
150
+ this.#queues.set(key, current);
90
151
  return current;
91
152
  }
92
153
 
93
154
  async waitForIdle() {
94
- 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
+ ]);
95
161
  }
96
162
 
97
- async #process(message) {
98
- if (message?.message_type === 2) return;
163
+ async #process(message, key, { alreadyRecorded = false } = {}) {
164
+ this.#signal?.throwIfAborted();
99
165
  const messageId = weixinMessageId(message);
100
- const sender = typeof message?.from_user_id === 'string' ? message.from_user_id : '';
166
+ const sender = nonEmptyString(message?.from_user_id);
101
167
  if (!messageId || !sender) return;
102
- if (this.#state.hasSeen(messageId)) return;
103
-
104
- this.#status.messagesReceived += 1;
105
- 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
+ }
106
173
  if (sender !== this.#ownerUserId) {
107
174
  this.#status.messagesRejected += 1;
108
175
  this.#status.lastRejectedAt = new Date().toISOString();
@@ -126,18 +193,18 @@ export class WeixinHarnessBridge {
126
193
  return;
127
194
  }
128
195
  if (command === '/status') {
129
- await this.#harness.ensureRunning();
196
+ await this.#harness.ensureRunning({ signal: this.#signal });
130
197
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
131
198
  await this.#state.markSeen(messageId);
132
199
  return;
133
200
  }
134
201
  if (command === '/new') {
135
- await this.#state.clearSession(conversationKey(sender));
202
+ await this.#state.clearSession(key);
136
203
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
137
204
  await this.#state.markSeen(messageId);
138
205
  return;
139
206
  }
140
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
207
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
141
208
  if (workspaceCommand) {
142
209
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
143
210
  await this.#send(sender, reply, contextToken, runId);
@@ -146,20 +213,37 @@ export class WeixinHarnessBridge {
146
213
  return;
147
214
  }
148
215
 
149
- const key = conversationKey(sender);
150
- const { answer } = await askInWorkspaceSession({
151
- harness: this.#harness,
152
- state: this.#state,
153
- key,
154
- text,
155
- askOptions: { timeoutMs: this.#replyTimeoutMs },
156
- });
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
+ }
157
240
  await this.#send(sender, answer, contextToken, runId);
158
241
  await this.#state.markSeen(messageId);
159
242
  this.#status.messagesReplied += 1;
160
243
  this.#status.lastReplyAt = new Date().toISOString();
161
244
  this.#status.lastError = null;
162
245
  } catch (error) {
246
+ if (this.#signal?.aborted) return;
163
247
  this.#status.lastError = error?.message ?? String(error);
164
248
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
165
249
  try {
@@ -171,6 +255,304 @@ export class WeixinHarnessBridge {
171
255
  }
172
256
  }
173
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
+
174
556
  async #send(toUserId, text, contextToken, runId) {
175
557
  for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
176
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;