@xmanrui/dsh-im 4.11.0 → 4.13.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.
Files changed (51) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +12 -2
  4. package/README.md +12 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +1211 -1034
  7. package/lib/index.js +270 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +16 -5
  10. package/plugin-src/client/channels/feishu/index.js +45 -36
  11. package/plugin-src/client/channels/qq/index.js +16 -5
  12. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +39 -28
  14. package/plugin-src/client/channels/wecom/index.js +16 -5
  15. package/plugin-src/client/channels/weixin/index.js +16 -5
  16. package/plugin-src/client/channels/whatsapp/index.js +16 -5
  17. package/plugin-src/client/i18n.js +2 -0
  18. package/plugin-src/client/styles.js +14 -1
  19. package/plugin-src/host/harness-command-executor.mjs +13 -6
  20. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  23. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  24. package/src/channels/dingtalk/state-store.mjs +8 -0
  25. package/src/channels/feishu/bridge.mjs +74 -3
  26. package/src/channels/feishu/state-store.mjs +11 -0
  27. package/src/channels/qq/qq-bridge.mjs +121 -1
  28. package/src/channels/qq/qq-menu.mjs +205 -0
  29. package/src/channels/qq/state-store.mjs +16 -0
  30. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  31. package/src/channels/shared/control-command.mjs +8 -0
  32. package/src/channels/shared/conversation-state-store.mjs +8 -0
  33. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  34. package/src/channels/shared/deferred-delivery.mjs +161 -0
  35. package/src/channels/shared/deferred-state.mjs +53 -0
  36. package/src/channels/shared/harness-client.mjs +21 -1
  37. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  38. package/src/channels/shared/i18n-en/feishu.mjs +6 -0
  39. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  40. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  41. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  42. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  43. package/src/channels/shared/workspace-session.mjs +16 -1
  44. package/src/channels/wecom/state-store.mjs +8 -0
  45. package/src/channels/wecom/wecom-bridge.mjs +294 -6
  46. package/src/channels/wecom/wecom-cards.mjs +101 -0
  47. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  48. package/src/channels/weixin/state-store.mjs +64 -1
  49. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  50. package/src/channels/weixin/weixin-runtime.mjs +33 -8
  51. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -1,3 +1,5 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
2
+ import { createHash } from 'node:crypto';
1
3
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
4
  import { dirname } from 'node:path';
3
5
 
@@ -15,6 +17,7 @@ export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
15
17
  export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
16
18
  export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
17
19
  export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
20
+ const CONTEXT_TOKEN_LIMIT = 1_000;
18
21
 
19
22
  function nonEmptyString(value) {
20
23
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -25,6 +28,26 @@ function timestamp(value) {
25
28
  return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
26
29
  }
27
30
 
31
+ function sequence(value) {
32
+ const text = typeof value === 'string' ? value : Number.isSafeInteger(value) ? String(value) : '';
33
+ return /^\d+$/.test(text) ? BigInt(text).toString() : null;
34
+ }
35
+
36
+ function normalizeContextTokens(value) {
37
+ if (!value || typeof value !== 'object' || !/^[a-f0-9]{64}$/.test(value.credentialHash ?? '')) return undefined;
38
+ const users = Object.create(null);
39
+ for (const [userId, entry] of Object.entries(value.users ?? {}).slice(-CONTEXT_TOKEN_LIMIT)) {
40
+ if (!nonEmptyString(userId) || !nonEmptyString(entry?.token)) continue;
41
+ users[userId] = {
42
+ token: entry.token,
43
+ seq: sequence(entry.seq),
44
+ messageTimeMs: timestamp(entry.messageTimeMs),
45
+ receivedAt: timestamp(entry.receivedAt),
46
+ };
47
+ }
48
+ return { credentialHash: value.credentialHash, users };
49
+ }
50
+
28
51
  function providerMessageIds(value) {
29
52
  if (!Array.isArray(value)) return [];
30
53
  return [...new Set(value
@@ -72,14 +95,17 @@ function normalizeState(value) {
72
95
  }
73
96
  }
74
97
  }
98
+ const contextTokens = normalizeContextTokens(value.contextTokens);
75
99
  return {
76
100
  version: 1,
77
101
  sessions,
102
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
78
103
  seenMessageIds: Array.isArray(value.seenMessageIds)
79
104
  ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
80
105
  : [],
81
106
  getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
82
107
  recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
108
+ ...(contextTokens ? { contextTokens } : {}),
83
109
  };
84
110
  }
85
111
 
@@ -87,6 +113,7 @@ export class WeixinStateStore {
87
113
  #path;
88
114
  #state = structuredClone(EMPTY_STATE);
89
115
  #writeQueue = Promise.resolve();
116
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
90
117
 
91
118
  constructor(path) {
92
119
  this.#path = path;
@@ -103,6 +130,41 @@ export class WeixinStateStore {
103
130
  return this;
104
131
  }
105
132
 
133
+ deferredEntries() { return this.#deferred.entries(); }
134
+ putDeferred(entry) { return this.#deferred.put(entry); }
135
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
136
+ removeDeferred(id) { return this.#deferred.remove(id); }
137
+
138
+ // Context belongs to this bot login, not to a Harness session or workspace.
139
+ async bindContextTokens(token) {
140
+ const credentialHash = createHash('sha256').update(token).digest('hex');
141
+ if (this.#state.contextTokens?.credentialHash === credentialHash) return;
142
+ this.#state.contextTokens = { credentialHash, users: Object.create(null) };
143
+ await this.#persist();
144
+ }
145
+
146
+ contextTokenFor(userId) {
147
+ return this.#state.contextTokens?.users?.[userId]?.token ?? undefined;
148
+ }
149
+
150
+ async rememberContextToken({ userId, contextToken, seq, messageTimeMs }) {
151
+ if (!nonEmptyString(userId) || !nonEmptyString(contextToken) || !this.#state.contextTokens) return;
152
+ const users = this.#state.contextTokens.users;
153
+ const previous = users[userId];
154
+ const next = { token: contextToken, seq: sequence(seq), messageTimeMs: timestamp(messageTimeMs), receivedAt: Date.now() };
155
+ // Late/duplicate batches must not replace a newer conversation capability.
156
+ if (previous) {
157
+ if (previous.seq !== null && next.seq !== null && BigInt(next.seq) <= BigInt(previous.seq)) return;
158
+ if (previous.messageTimeMs !== null && next.messageTimeMs !== null
159
+ && next.messageTimeMs < previous.messageTimeMs) return;
160
+ }
161
+ delete users[userId];
162
+ users[userId] = next;
163
+ const keys = Object.keys(users);
164
+ if (keys.length > CONTEXT_TOKEN_LIMIT) delete users[keys[0]];
165
+ await this.#persist();
166
+ }
167
+
106
168
  sessionFor(key) {
107
169
  return this.#state.sessions[key] ?? null;
108
170
  }
@@ -201,7 +263,8 @@ export class WeixinStateStore {
201
263
  }
202
264
 
203
265
  snapshot() {
204
- return structuredClone(this.#state);
266
+ const { contextTokens, ...state } = this.#state;
267
+ return structuredClone(state);
205
268
  }
206
269
 
207
270
  async remove() {
@@ -1,3 +1,4 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
1
2
  import {
2
3
  DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
3
4
  extractWeixinFiles,
@@ -163,7 +164,7 @@ function createWeixinSendDiagnostic({
163
164
  ].join(' ');
164
165
  }
165
166
 
166
- function weixinSendError(error, details) {
167
+ export function weixinSendError(error, details) {
167
168
  const diagnostic = createWeixinSendDiagnostic({ ...details, error });
168
169
  const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
169
170
  const code = safeDiagnosticToken(error?.code);
@@ -176,7 +177,7 @@ function weixinSendError(error, details) {
176
177
  return wrapped;
177
178
  }
178
179
 
179
- function weixinSendFailureOptions(error) {
180
+ export function weixinSendFailureOptions(error, { proactive = false } = {}) {
180
181
  let current = error;
181
182
  for (let depth = 0; current && depth < 5; depth += 1) {
182
183
  const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
@@ -184,7 +185,12 @@ function weixinSendFailureOptions(error) {
184
185
  return {
185
186
  reason: 'weixin-send-failed',
186
187
  userMessage: [
187
- t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
188
+ proactive
189
+ ? t('微信主动消息发送失败。长轮询在线不代表消息可以发送。')
190
+ : t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
191
+ ...(String(current.providerCode) === '-2' ? [
192
+ t('iLink 拒绝发送,可能涉及会话有效期、发送额度或消息内容。可让接收者发一条消息后重试;不要通过反复发送心跳尝试续期。'),
193
+ ] : []),
188
194
  t('微信发送诊断:{diagnostic}', { diagnostic }),
189
195
  ].join('\n'),
190
196
  };
@@ -289,6 +295,7 @@ export class WeixinHarnessBridge {
289
295
  #ownerUserId;
290
296
  #harness;
291
297
  #state;
298
+ #deferred;
292
299
  #contextEnhancement;
293
300
  #accessPolicy;
294
301
  #status;
@@ -351,6 +358,9 @@ export class WeixinHarnessBridge {
351
358
  this.#maxMessageChars = maxMessageChars;
352
359
  this.#typingKeepaliveMs = typingKeepaliveMs;
353
360
  this.#signal = signal;
361
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
362
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
363
+ });
354
364
  this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
355
365
  }
356
366
 
@@ -390,6 +400,12 @@ export class WeixinHarnessBridge {
390
400
  }
391
401
  const key = conversationKey(sender);
392
402
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
403
+ void this.#state.rememberContextToken?.({
404
+ userId: sender,
405
+ contextToken,
406
+ seq: message.seq,
407
+ messageTimeMs: message.create_time_ms ?? weixinMessageTimestampMs(messageId),
408
+ }).catch((error) => this.#logger.warn?.('[dsh-weixin] failed to persist conversation context:', error));
393
409
  const runId = nonEmptyString(message?.run_id) ?? undefined;
394
410
  const pending = this.#pendingInteractions.get(key);
395
411
  const batchCommand = isBatchInputCommand(commandText);
@@ -642,6 +658,11 @@ export class WeixinHarnessBridge {
642
658
  return task;
643
659
  }
644
660
 
661
+ async #deliverDeferredOutcome(entry, outcome) {
662
+ await this.#send(entry.target.toUserId, deferredOutcomeText(outcome), undefined, undefined, { stopTyping: false });
663
+ return true;
664
+ }
665
+
645
666
  async waitForIdle() {
646
667
  await Promise.allSettled([
647
668
  ...this.#queues.values(),
@@ -652,9 +673,11 @@ export class WeixinHarnessBridge {
652
673
  ...this.#commandTasks,
653
674
  this.#typingTail,
654
675
  ]);
676
+ await this.#deferred.whenIdle();
655
677
  }
656
678
 
657
679
  async close() {
680
+ this.#deferred.close();
658
681
  this.#typingClosed = true;
659
682
  await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
660
683
  }
@@ -682,6 +705,7 @@ export class WeixinHarnessBridge {
682
705
  pendingInteraction: this.#pendingInteractions.has(key)
683
706
  || this.#approvals.hasPending(key),
684
707
  control: { owner: this, key },
708
+ deferredDelivery: this.#deferred,
685
709
  });
686
710
  if (result?.stopped) {
687
711
  await Promise.allSettled([
@@ -794,6 +818,7 @@ export class WeixinHarnessBridge {
794
818
  await this.#state.markSeen(messageId);
795
819
  promptRecorded = true;
796
820
  ({ answer, artifacts = [] } = await askInWorkspaceSession({
821
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: { toUserId: sender } }),
797
822
  harness: this.#harness,
798
823
  state: this.#state,
799
824
  key,
@@ -1225,8 +1250,9 @@ export class WeixinHarnessBridge {
1225
1250
  ).catch(() => undefined);
1226
1251
  }
1227
1252
 
1228
- async #send(toUserId, text, contextToken, runId) {
1229
- await this.#stopTyping();
1253
+ async #send(toUserId, text, contextToken, runId, { stopTyping = true } = {}) {
1254
+ if (stopTyping) await this.#stopTyping();
1255
+ contextToken ??= this.#state.contextTokenFor?.(toUserId);
1230
1256
  const providerMessageIds = [];
1231
1257
  const chunks = splitWeixinText(text, this.#maxMessageChars);
1232
1258
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
@@ -1,6 +1,11 @@
1
1
  import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
2
- import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
2
+ import {
3
+ createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
4
+ } from './weixin-bridge.mjs';
3
5
  import { providerMessageIdsFor } from '../shared/semantic/delivery.mjs';
6
+ import {
7
+ channelDeliveryFailure, clearLastMessageFailure, setLastMessageFailure,
8
+ } from '../shared/message-failure.mjs';
4
9
  import {
5
10
  connectionTestTarget,
6
11
  connectionTestTargetUnavailable,
@@ -175,6 +180,7 @@ export class WeixinRuntime {
175
180
  throw harnessHealthError(error);
176
181
  }
177
182
  this.#status.harnessReachable = true;
183
+ await this.#state.bindContextTokens?.(this.#token);
178
184
  await this.#notifyStart();
179
185
  this.#abortController = new AbortController();
180
186
  const signal = this.#abortController.signal;
@@ -335,13 +341,32 @@ export class WeixinRuntime {
335
341
 
336
342
  async #sendTrackedText({ toUserId, text, signal }) {
337
343
  const sentAt = Date.now();
338
- const result = await this.#api.sendText({
339
- baseUrl: this.#config.baseUrl,
340
- token: this.#token,
341
- toUserId,
342
- text,
343
- signal,
344
- });
344
+ const contextToken = this.#state.contextTokenFor?.(toUserId);
345
+ let result;
346
+ try {
347
+ result = await this.#api.sendText({
348
+ baseUrl: this.#config.baseUrl,
349
+ token: this.#token,
350
+ toUserId,
351
+ text,
352
+ contextToken,
353
+ signal,
354
+ });
355
+ } catch (cause) {
356
+ if (signal?.aborted) throw cause;
357
+ const error = weixinSendError(cause, {
358
+ baseUrl: this.#config.baseUrl, text, chunk: text, chunkIndex: 0, chunkCount: 1,
359
+ maxMessageChars: this.#maxMessageChars, contextToken,
360
+ });
361
+ const failure = setLastMessageFailure(this.#status,
362
+ channelDeliveryFailure(error, { uncertain: cause?.code !== 'send-rejected' }),
363
+ weixinSendFailureOptions(error, { proactive: true }));
364
+ this.#logger.warn?.(`[dsh-weixin] outbound delivery failed [${failure.referenceId}]: ${failure.message}`);
365
+ throw error;
366
+ }
367
+ if (this.#status.lastMessageError?.reason === 'WEIXIN_SEND_FAILED') {
368
+ clearLastMessageFailure(this.#status);
369
+ }
345
370
  try {
346
371
  await this.#state.rememberOutboundMessage?.({
347
372
  toUserId,
@@ -7,7 +7,7 @@ import {
7
7
  normalizeMessageContent,
8
8
  } from '@whiskeysockets/baileys';
9
9
 
10
- import { splitMessageText } from '../shared/editable-message-stream.mjs';
10
+ import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
11
11
  import { t } from '../shared/i18n.mjs';
12
12
  import { ImagePromptError } from '../shared/image-prompt.mjs';
13
13
  import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
@@ -21,6 +21,7 @@ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'ima
21
21
  const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
22
22
  const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
23
23
  const WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS = 120_000;
24
+ const WHATSAPP_TEXT_LIMIT = 4_000;
24
25
  const MESSAGE_WRAPPER_KEYS = [
25
26
  'ephemeralMessage',
26
27
  'viewOnceMessage',
@@ -441,49 +442,97 @@ export class WhatsappBotClient {
441
442
  #socket;
442
443
  #outboundIds;
443
444
  #signal;
445
+ #abortController = new AbortController();
444
446
  #mediaUploadTimeoutMs;
447
+ #logger;
445
448
  #typingTimers = new Map();
449
+ #streams = new Set();
446
450
 
447
451
  constructor(socket, outboundIds, {
448
452
  signal,
449
453
  mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
454
+ logger = console,
450
455
  } = {}) {
451
456
  this.#socket = socket;
452
457
  this.#outboundIds = outboundIds;
453
- this.#signal = signal;
458
+ this.#signal = signal
459
+ ? AbortSignal.any([signal, this.#abortController.signal])
460
+ : this.#abortController.signal;
454
461
  this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
462
+ this.#logger = logger;
455
463
  }
456
464
 
457
465
  async sendText(target, text) {
458
466
  await this.#stopTyping(target.jid);
459
467
  const providerMessageIds = [];
460
- for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
461
- const messageId = randomBytes(10).toString('hex').toUpperCase();
462
- const options = {
463
- ...(index === 0 && target.quoted ? { quoted: target.quoted } : {}),
464
- messageId,
465
- };
466
- // Linked-account group messages are valid inbound prompts in open mode.
467
- // Reserve our own id before dispatch so an early local echo cannot loop
468
- // back through the bridge as another owner-authored group prompt.
469
- if (typeof this.#outboundIds.reserve === 'function') {
470
- this.#outboundIds.reserve(messageId);
471
- } else {
472
- this.#outboundIds.remember(messageId);
473
- }
474
- const result = await this.#socket.sendMessage(
475
- target.jid,
476
- { text: chunk },
477
- options,
478
- );
479
- this.#outboundIds.remember(result?.key?.id);
480
- if (typeof result?.key?.id === 'string' && result.key.id) {
481
- providerMessageIds.push(result.key.id);
482
- }
468
+ for (const [index, chunk] of splitMessageText(text, WHATSAPP_TEXT_LIMIT).entries()) {
469
+ const result = await this.#sendTextMessage(target, chunk, { quote: index === 0 });
470
+ providerMessageIds.push(result.key.id);
483
471
  }
484
472
  return { providerMessageIds };
485
473
  }
486
474
 
475
+ async #sendTextMessage(target, text, { edit, quote = true } = {}) {
476
+ this.#signal?.throwIfAborted();
477
+ const messageId = randomBytes(10).toString('hex').toUpperCase();
478
+ // Reserve both the outgoing envelope and the edited message before dispatch:
479
+ // linked-account group and self-chat echoes can arrive before send settles.
480
+ const reserve = (id) => {
481
+ if (typeof this.#outboundIds.reserve === 'function') this.#outboundIds.reserve(id);
482
+ else this.#outboundIds.remember(id);
483
+ };
484
+ reserve(messageId);
485
+ if (edit) reserve(edit.id);
486
+ const pending = this.#socket.sendMessage(
487
+ target.jid,
488
+ { text, ...(edit ? { edit } : {}) },
489
+ { ...(quote && !edit && target.quoted ? { quoted: target.quoted } : {}), messageId },
490
+ );
491
+ const result = await waitWithSignal(pending, this.#signal);
492
+ this.#outboundIds.remember(result?.key?.id);
493
+ return {
494
+ ...result,
495
+ key: { remoteJid: target.jid, fromMe: true, id: messageId, ...result?.key },
496
+ };
497
+ }
498
+
499
+ async openStream(target) {
500
+ let messageKey;
501
+ const stream = createEditableMessageStream({
502
+ limit: WHATSAPP_TEXT_LIMIT,
503
+ updateIntervalMs: 1_000,
504
+ create: async (text) => {
505
+ const message = await this.#sendTextMessage(target, text);
506
+ messageKey = message.key;
507
+ return messageKey.id;
508
+ },
509
+ // Baileys edits need the original full message key, not an edit envelope id.
510
+ edit: async (_messageId, text) => this.#sendTextMessage(target, text, { edit: messageKey }),
511
+ sendRemainder: (text) => this.#sendTextMessage(target, text, { quote: false }),
512
+ messageIdForResult: (message) => message?.key?.id,
513
+ logger: this.#logger,
514
+ });
515
+ const finish = stream.finish.bind(stream);
516
+ const cancel = stream.cancel.bind(stream);
517
+ stream.finish = async (text) => {
518
+ try {
519
+ return await finish(text);
520
+ } finally {
521
+ this.#streams.delete(stream);
522
+ await this.#stopTyping(target.jid);
523
+ }
524
+ };
525
+ stream.cancel = () => {
526
+ cancel();
527
+ this.#streams.delete(stream);
528
+ return this.#stopTyping(target.jid);
529
+ };
530
+ await stream.start();
531
+ this.#signal.throwIfAborted();
532
+ this.#streams.add(stream);
533
+ return stream;
534
+ }
535
+
487
536
  async sendFile(target, file) {
488
537
  return this.#sendArtifact(target, file, {
489
538
  document: file.bytes,
@@ -593,8 +642,10 @@ export class WhatsappBotClient {
593
642
  }
594
643
 
595
644
  async close() {
645
+ this.#abortController.abort();
646
+ const stoppingStreams = [...this.#streams].map((stream) => stream.cancel());
596
647
  const jids = [...this.#typingTimers.keys()];
597
- await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
648
+ await Promise.allSettled([...stoppingStreams, ...jids.map((jid) => this.#stopTyping(jid))]);
598
649
  }
599
650
 
600
651
  async #stopTyping(jid, sendPaused = true) {
@@ -741,6 +792,7 @@ export class WhatsappRuntime {
741
792
  const client = new WhatsappBotClient(session.socket, outboundIds, {
742
793
  signal: controller.signal,
743
794
  mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
795
+ logger: this.#logger,
744
796
  });
745
797
  this.#client = client;
746
798
  this.#bridge = new WhatsappHarnessBridge({