@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.
- package/PROACTIVE_DELIVERY.en.md +4 -0
- package/PROACTIVE_DELIVERY.md +4 -0
- package/README.en.md +12 -2
- package/README.md +12 -2
- package/assets/dingtalk-menu-template.json +6 -0
- package/lib/client.js +1211 -1034
- package/lib/index.js +270 -264
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/index.js +16 -5
- package/plugin-src/client/channels/feishu/index.js +45 -36
- package/plugin-src/client/channels/qq/index.js +16 -5
- package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
- package/plugin-src/client/channels/shared/token-channel.js +39 -28
- package/plugin-src/client/channels/wecom/index.js +16 -5
- package/plugin-src/client/channels/weixin/index.js +16 -5
- package/plugin-src/client/channels/whatsapp/index.js +16 -5
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/styles.js +14 -1
- package/plugin-src/host/harness-command-executor.mjs +13 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
- package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
- package/src/channels/dingtalk/state-store.mjs +8 -0
- package/src/channels/feishu/bridge.mjs +74 -3
- package/src/channels/feishu/state-store.mjs +11 -0
- package/src/channels/qq/qq-bridge.mjs +121 -1
- package/src/channels/qq/qq-menu.mjs +205 -0
- package/src/channels/qq/state-store.mjs +16 -0
- package/src/channels/shared/bot-workspace-store.mjs +3 -0
- package/src/channels/shared/control-command.mjs +8 -0
- package/src/channels/shared/conversation-state-store.mjs +8 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
- package/src/channels/shared/deferred-delivery.mjs +161 -0
- package/src/channels/shared/deferred-state.mjs +53 -0
- package/src/channels/shared/harness-client.mjs +21 -1
- package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
- package/src/channels/shared/i18n-en/feishu.mjs +6 -0
- package/src/channels/shared/i18n-en/qq.mjs +39 -0
- package/src/channels/shared/i18n-en/wecom.mjs +24 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/text-harness-bridge.mjs +15 -0
- package/src/channels/shared/workspace-session.mjs +16 -1
- package/src/channels/wecom/state-store.mjs +8 -0
- package/src/channels/wecom/wecom-bridge.mjs +294 -6
- package/src/channels/wecom/wecom-cards.mjs +101 -0
- package/src/channels/wecom/wecom-runtime.mjs +2 -0
- package/src/channels/weixin/state-store.mjs +64 -1
- package/src/channels/weixin/weixin-bridge.mjs +31 -5
- package/src/channels/weixin/weixin-runtime.mjs +33 -8
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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,
|
|
461
|
-
const
|
|
462
|
-
|
|
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({
|