@gakr-gakr/whatsapp 0.1.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/action-runtime-api.ts +1 -0
- package/action-runtime.runtime.ts +1 -0
- package/api.ts +67 -0
- package/auth-presence.ts +80 -0
- package/autobot.plugin.json +23 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +3 -0
- package/config-api.ts +4 -0
- package/constants.ts +1 -0
- package/contract-api.ts +29 -0
- package/directory-contract-api.ts +4 -0
- package/doctor-contract-api.ts +8 -0
- package/index.ts +16 -0
- package/legacy-session-surface-api.ts +6 -0
- package/legacy-state-migrations-api.ts +1 -0
- package/light-runtime-api.ts +12 -0
- package/login-qr-api.ts +1 -0
- package/login-qr-runtime.ts +23 -0
- package/outbound-payload-test-api.ts +1 -0
- package/package.json +76 -0
- package/runtime-api.ts +84 -0
- package/secret-contract-api.ts +4 -0
- package/security-contract-api.ts +4 -0
- package/setup-entry.ts +21 -0
- package/setup-plugin-api.ts +3 -0
- package/src/account-config.ts +77 -0
- package/src/account-ids.ts +17 -0
- package/src/account-types.ts +5 -0
- package/src/accounts.ts +176 -0
- package/src/action-runtime-target-auth.ts +27 -0
- package/src/action-runtime.ts +76 -0
- package/src/active-listener.ts +17 -0
- package/src/agent-tools-login.ts +113 -0
- package/src/approval-auth.ts +27 -0
- package/src/auth-store.runtime.ts +1 -0
- package/src/auth-store.ts +494 -0
- package/src/auto-reply/config.runtime.ts +16 -0
- package/src/auto-reply/constants.ts +1 -0
- package/src/auto-reply/deliver-reply.ts +332 -0
- package/src/auto-reply/loggers.ts +6 -0
- package/src/auto-reply/mentions.ts +131 -0
- package/src/auto-reply/monitor/ack-reaction.ts +99 -0
- package/src/auto-reply/monitor/audio-preflight.runtime.ts +9 -0
- package/src/auto-reply/monitor/broadcast.ts +153 -0
- package/src/auto-reply/monitor/commands.ts +19 -0
- package/src/auto-reply/monitor/echo.ts +64 -0
- package/src/auto-reply/monitor/group-activation.runtime.ts +1 -0
- package/src/auto-reply/monitor/group-activation.ts +73 -0
- package/src/auto-reply/monitor/group-gating.runtime.ts +8 -0
- package/src/auto-reply/monitor/group-gating.ts +218 -0
- package/src/auto-reply/monitor/group-members.ts +65 -0
- package/src/auto-reply/monitor/inbound-context.ts +92 -0
- package/src/auto-reply/monitor/inbound-dispatch.runtime.ts +22 -0
- package/src/auto-reply/monitor/inbound-dispatch.ts +749 -0
- package/src/auto-reply/monitor/last-route.ts +61 -0
- package/src/auto-reply/monitor/listener-log.ts +28 -0
- package/src/auto-reply/monitor/message-line.runtime.ts +38 -0
- package/src/auto-reply/monitor/message-line.ts +54 -0
- package/src/auto-reply/monitor/on-message.ts +333 -0
- package/src/auto-reply/monitor/peer.ts +17 -0
- package/src/auto-reply/monitor/process-message.ts +584 -0
- package/src/auto-reply/monitor/runtime-api.ts +36 -0
- package/src/auto-reply/monitor/status-reaction.ts +108 -0
- package/src/auto-reply/monitor-state.ts +114 -0
- package/src/auto-reply/monitor.ts +720 -0
- package/src/auto-reply/reply-resolver.runtime.ts +1 -0
- package/src/auto-reply/types.ts +48 -0
- package/src/auto-reply/util.ts +62 -0
- package/src/auto-reply.impl.ts +6 -0
- package/src/auto-reply.ts +1 -0
- package/src/channel-actions.runtime.ts +7 -0
- package/src/channel-actions.ts +85 -0
- package/src/channel-outbound.ts +87 -0
- package/src/channel-react-action.runtime.ts +10 -0
- package/src/channel-react-action.ts +247 -0
- package/src/channel.runtime.ts +117 -0
- package/src/channel.setup.ts +32 -0
- package/src/channel.ts +356 -0
- package/src/command-policy.ts +7 -0
- package/src/config-accessors.ts +22 -0
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +24 -0
- package/src/connection-controller-registry.ts +49 -0
- package/src/connection-controller.ts +680 -0
- package/src/creds-files.ts +19 -0
- package/src/creds-persistence.ts +71 -0
- package/src/directory-config.ts +40 -0
- package/src/doctor-contract.ts +11 -0
- package/src/doctor.ts +56 -0
- package/src/document-filename.ts +17 -0
- package/src/group-intro.ts +15 -0
- package/src/group-policy.ts +40 -0
- package/src/group-session-contract.ts +20 -0
- package/src/group-session-key.ts +42 -0
- package/src/heartbeat.ts +34 -0
- package/src/identity.ts +164 -0
- package/src/inbound/access-control.ts +187 -0
- package/src/inbound/dedupe.ts +132 -0
- package/src/inbound/extract.ts +484 -0
- package/src/inbound/lifecycle.ts +39 -0
- package/src/inbound/media.ts +128 -0
- package/src/inbound/monitor.ts +1042 -0
- package/src/inbound/outbound-mentions.ts +260 -0
- package/src/inbound/runtime-api.ts +7 -0
- package/src/inbound/save-media.runtime.ts +1 -0
- package/src/inbound/send-api.ts +203 -0
- package/src/inbound/send-result.ts +109 -0
- package/src/inbound/types.ts +107 -0
- package/src/inbound-policy.ts +215 -0
- package/src/inbound.ts +9 -0
- package/src/login-qr.ts +542 -0
- package/src/login.ts +83 -0
- package/src/media.ts +10 -0
- package/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +417 -0
- package/src/monitor-inbox.append-upsert.test-support.ts +133 -0
- package/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +418 -0
- package/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +308 -0
- package/src/monitor-inbox.streams-inbound-messages.test-support.ts +824 -0
- package/src/normalize-target.ts +148 -0
- package/src/normalize.ts +8 -0
- package/src/outbound-adapter.ts +36 -0
- package/src/outbound-base.ts +256 -0
- package/src/outbound-media-contract.ts +307 -0
- package/src/outbound-media.runtime.ts +41 -0
- package/src/outbound-send-deps.ts +1 -0
- package/src/outbound-test-support.ts +16 -0
- package/src/qa-driver.runtime.ts +189 -0
- package/src/qr-image.ts +1 -0
- package/src/qr-terminal.ts +1 -0
- package/src/quoted-message.ts +184 -0
- package/src/reaction-level.ts +24 -0
- package/src/reconnect.ts +55 -0
- package/src/resolve-outbound-target.ts +58 -0
- package/src/runtime-api.ts +59 -0
- package/src/runtime-group-policy.ts +16 -0
- package/src/runtime.ts +9 -0
- package/src/security-contract.ts +47 -0
- package/src/security-fix.ts +71 -0
- package/src/send.ts +342 -0
- package/src/session-contract.ts +43 -0
- package/src/session-errors.ts +125 -0
- package/src/session-route.ts +32 -0
- package/src/session.runtime.ts +8 -0
- package/src/session.ts +327 -0
- package/src/setup-core.ts +52 -0
- package/src/setup-finalize.ts +450 -0
- package/src/setup-surface.ts +71 -0
- package/src/setup-test-helpers.ts +217 -0
- package/src/shared.ts +291 -0
- package/src/socket-timing.ts +38 -0
- package/src/state-migrations.ts +55 -0
- package/src/status-issues.ts +185 -0
- package/src/system-prompt.ts +31 -0
- package/src/targets-runtime.ts +221 -0
- package/src/text-runtime.ts +18 -0
- package/src/vcard.ts +84 -0
- package/targets.ts +5 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "autobot/plugin-sdk/media-runtime";
|
|
3
|
+
import { sanitizeForPlainText } from "autobot/plugin-sdk/outbound-runtime";
|
|
4
|
+
import { writeExternalFileWithinRoot } from "autobot/plugin-sdk/security-runtime";
|
|
5
|
+
import { resolvePreferredAutoBotTmpDir, withTempWorkspace } from "autobot/plugin-sdk/temp-path";
|
|
6
|
+
import { resolveWhatsAppDocumentFileName } from "./document-filename.js";
|
|
7
|
+
import { formatError } from "./session-errors.js";
|
|
8
|
+
import {
|
|
9
|
+
sanitizeAssistantVisibleText,
|
|
10
|
+
sanitizeAssistantVisibleTextWithProfile,
|
|
11
|
+
stripToolCallXmlTags,
|
|
12
|
+
sleep,
|
|
13
|
+
} from "./text-runtime.js";
|
|
14
|
+
|
|
15
|
+
type WhatsAppOutboundPayloadLike = {
|
|
16
|
+
text?: string;
|
|
17
|
+
mediaUrl?: string;
|
|
18
|
+
mediaUrls?: readonly string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type WhatsAppLoadedMediaLike = {
|
|
22
|
+
buffer: Buffer;
|
|
23
|
+
contentType?: string;
|
|
24
|
+
kind?: string;
|
|
25
|
+
fileName?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type NormalizedWhatsAppOutboundPayload<T extends WhatsAppOutboundPayloadLike> = Omit<
|
|
29
|
+
T,
|
|
30
|
+
"text" | "mediaUrl" | "mediaUrls"
|
|
31
|
+
> & {
|
|
32
|
+
text: string;
|
|
33
|
+
mediaUrl?: string;
|
|
34
|
+
mediaUrls?: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DeliverableWhatsAppOutboundPayload<T extends WhatsAppOutboundPayloadLike> = Omit<
|
|
38
|
+
NormalizedWhatsAppOutboundPayload<T>,
|
|
39
|
+
"text"
|
|
40
|
+
> & {
|
|
41
|
+
text?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type CanonicalWhatsAppLoadedMedia = {
|
|
45
|
+
buffer: Buffer;
|
|
46
|
+
kind: "image" | "audio" | "video" | "document";
|
|
47
|
+
mimetype: string;
|
|
48
|
+
fileName?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const WHATSAPP_VOICE_FILE_NAME = "voice.ogg";
|
|
52
|
+
const WHATSAPP_VOICE_SAMPLE_RATE_HZ = 48_000;
|
|
53
|
+
const WHATSAPP_VOICE_BITRATE = "64k";
|
|
54
|
+
const WHATSAPP_VOICE_MIMETYPE = "audio/ogg; codecs=opus";
|
|
55
|
+
|
|
56
|
+
function stripWhatsAppPluralToolXml(text: string): string {
|
|
57
|
+
return stripToolCallXmlTags(text, { stripFunctionCallsXmlPayloads: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finalizeWhatsAppVisibleText(text: string): string {
|
|
61
|
+
return sanitizeForPlainText(stripWhatsAppPluralToolXml(text));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeWhatsAppPayloadText(text: string | undefined): string {
|
|
65
|
+
return finalizeWhatsAppVisibleText(sanitizeAssistantVisibleText(text ?? "")).trimStart();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stripLeadingBlankLines(text: string): string {
|
|
69
|
+
return text.replace(/^(?:[ \t]*\r?\n)+/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeWhatsAppPayloadTextPreservingIndentation(
|
|
73
|
+
text: string | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const sanitized = sanitizeAssistantVisibleTextWithProfile(
|
|
76
|
+
stripLeadingBlankLines(text ?? ""),
|
|
77
|
+
"history",
|
|
78
|
+
);
|
|
79
|
+
const normalized = stripLeadingBlankLines(finalizeWhatsAppVisibleText(sanitized));
|
|
80
|
+
return normalized.trim() ? normalized : "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveWhatsAppOutboundMediaUrls(
|
|
84
|
+
payload: Pick<WhatsAppOutboundPayloadLike, "mediaUrl" | "mediaUrls">,
|
|
85
|
+
): string[] {
|
|
86
|
+
const primaryMediaUrl = payload.mediaUrl?.trim();
|
|
87
|
+
const mediaUrls = (payload.mediaUrls ? [...payload.mediaUrls] : [])
|
|
88
|
+
.map((entry) => entry.trim())
|
|
89
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
90
|
+
const orderedMediaUrls = [primaryMediaUrl, ...mediaUrls].filter((entry): entry is string =>
|
|
91
|
+
Boolean(entry),
|
|
92
|
+
);
|
|
93
|
+
return Array.from(new Set(orderedMediaUrls));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Keep new WhatsApp outbound-media behavior in this helper so payload, gateway, and auto-reply paths stay aligned.
|
|
97
|
+
export function normalizeWhatsAppOutboundPayload<T extends WhatsAppOutboundPayloadLike>(
|
|
98
|
+
payload: T,
|
|
99
|
+
options?: {
|
|
100
|
+
normalizeText?: (text: string | undefined) => string;
|
|
101
|
+
},
|
|
102
|
+
): NormalizedWhatsAppOutboundPayload<T> {
|
|
103
|
+
const mediaUrls = resolveWhatsAppOutboundMediaUrls(payload);
|
|
104
|
+
const normalizeText = options?.normalizeText ?? normalizeWhatsAppPayloadText;
|
|
105
|
+
return {
|
|
106
|
+
...payload,
|
|
107
|
+
text: normalizeText(payload.text),
|
|
108
|
+
mediaUrl: mediaUrls[0],
|
|
109
|
+
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function inferWhatsAppMediaKind(
|
|
114
|
+
media: WhatsAppLoadedMediaLike,
|
|
115
|
+
): "image" | "audio" | "video" | "document" {
|
|
116
|
+
if (
|
|
117
|
+
media.kind === "image" ||
|
|
118
|
+
media.kind === "audio" ||
|
|
119
|
+
media.kind === "video" ||
|
|
120
|
+
media.kind === "document"
|
|
121
|
+
) {
|
|
122
|
+
return media.kind;
|
|
123
|
+
}
|
|
124
|
+
const contentType = normalizeContentType(media.contentType);
|
|
125
|
+
if (contentType.startsWith("image/")) {
|
|
126
|
+
return "image";
|
|
127
|
+
}
|
|
128
|
+
if (contentType.startsWith("audio/")) {
|
|
129
|
+
return "audio";
|
|
130
|
+
}
|
|
131
|
+
if (contentType.startsWith("video/")) {
|
|
132
|
+
return "video";
|
|
133
|
+
}
|
|
134
|
+
return "document";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeWhatsAppLoadedMedia(
|
|
138
|
+
media: WhatsAppLoadedMediaLike,
|
|
139
|
+
mediaUrl?: string,
|
|
140
|
+
): CanonicalWhatsAppLoadedMedia {
|
|
141
|
+
const kind = inferWhatsAppMediaKind(media);
|
|
142
|
+
const mimetype =
|
|
143
|
+
kind === "audio" && isWhatsAppNativeVoiceAudio({ contentType: media.contentType, mediaUrl })
|
|
144
|
+
? WHATSAPP_VOICE_MIMETYPE
|
|
145
|
+
: (media.contentType ?? "application/octet-stream");
|
|
146
|
+
const fileName =
|
|
147
|
+
kind === "document"
|
|
148
|
+
? resolveWhatsAppDocumentFileName({
|
|
149
|
+
fileName: media.fileName ?? deriveWhatsAppDocumentFileName(mediaUrl),
|
|
150
|
+
mimetype,
|
|
151
|
+
})
|
|
152
|
+
: media.fileName;
|
|
153
|
+
return {
|
|
154
|
+
buffer: media.buffer,
|
|
155
|
+
kind,
|
|
156
|
+
mimetype,
|
|
157
|
+
...(fileName ? { fileName } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function prepareWhatsAppOutboundMedia(
|
|
162
|
+
media: WhatsAppLoadedMediaLike,
|
|
163
|
+
mediaUrl?: string,
|
|
164
|
+
): Promise<CanonicalWhatsAppLoadedMedia> {
|
|
165
|
+
const normalized = normalizeWhatsAppLoadedMedia(media, mediaUrl);
|
|
166
|
+
if (normalized.kind !== "audio") {
|
|
167
|
+
return normalized;
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
isWhatsAppNativeVoiceAudio({
|
|
171
|
+
contentType: media.contentType,
|
|
172
|
+
fileName: media.fileName,
|
|
173
|
+
mediaUrl,
|
|
174
|
+
})
|
|
175
|
+
) {
|
|
176
|
+
return normalized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const buffer = await transcodeToWhatsAppVoiceOpus({
|
|
180
|
+
buffer: media.buffer,
|
|
181
|
+
fileName: media.fileName ?? deriveWhatsAppDocumentFileName(mediaUrl) ?? "audio",
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
buffer,
|
|
185
|
+
kind: "audio",
|
|
186
|
+
mimetype: WHATSAPP_VOICE_MIMETYPE,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeContentType(value: string | undefined): string {
|
|
191
|
+
return value?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isWhatsAppNativeVoiceAudio(params: {
|
|
195
|
+
contentType?: string;
|
|
196
|
+
fileName?: string;
|
|
197
|
+
mediaUrl?: string;
|
|
198
|
+
}): boolean {
|
|
199
|
+
const contentType = normalizeContentType(params.contentType);
|
|
200
|
+
if (contentType === "audio/ogg" || contentType === "audio/opus") {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const fileName = params.fileName ?? deriveWhatsAppDocumentFileName(params.mediaUrl) ?? "";
|
|
204
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
205
|
+
return ext === ".ogg" || ext === ".opus";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function transcodeToWhatsAppVoiceOpus(params: {
|
|
209
|
+
buffer: Buffer;
|
|
210
|
+
fileName: string;
|
|
211
|
+
}): Promise<Buffer> {
|
|
212
|
+
return await withTempWorkspace(
|
|
213
|
+
{ rootDir: resolvePreferredAutoBotTmpDir(), prefix: "whatsapp-voice-" },
|
|
214
|
+
async (workspace) => {
|
|
215
|
+
const ext = path.extname(params.fileName).toLowerCase();
|
|
216
|
+
const inputExt = ext && ext.length <= 12 ? ext : ".audio";
|
|
217
|
+
const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
|
|
218
|
+
await writeExternalFileWithinRoot({
|
|
219
|
+
rootDir: workspace.dir,
|
|
220
|
+
path: WHATSAPP_VOICE_FILE_NAME,
|
|
221
|
+
write: async (outputPath) => {
|
|
222
|
+
await runFfmpeg([
|
|
223
|
+
"-hide_banner",
|
|
224
|
+
"-loglevel",
|
|
225
|
+
"error",
|
|
226
|
+
"-y",
|
|
227
|
+
"-i",
|
|
228
|
+
inputPath,
|
|
229
|
+
"-vn",
|
|
230
|
+
"-sn",
|
|
231
|
+
"-dn",
|
|
232
|
+
"-t",
|
|
233
|
+
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
|
|
234
|
+
"-ar",
|
|
235
|
+
String(WHATSAPP_VOICE_SAMPLE_RATE_HZ),
|
|
236
|
+
"-ac",
|
|
237
|
+
"1",
|
|
238
|
+
"-c:a",
|
|
239
|
+
"libopus",
|
|
240
|
+
"-b:a",
|
|
241
|
+
WHATSAPP_VOICE_BITRATE,
|
|
242
|
+
"-f",
|
|
243
|
+
"ogg",
|
|
244
|
+
outputPath,
|
|
245
|
+
]);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
return await workspace.read(WHATSAPP_VOICE_FILE_NAME);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function deriveWhatsAppDocumentFileName(mediaUrl: string | undefined): string | undefined {
|
|
254
|
+
if (!mediaUrl) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new URL(mediaUrl);
|
|
259
|
+
const fileName = path.posix.basename(parsed.pathname);
|
|
260
|
+
return fileName ? decodeURIComponent(fileName) : undefined;
|
|
261
|
+
} catch {
|
|
262
|
+
const withoutQueryOrFragment = mediaUrl.split(/[?#]/, 1)[0] ?? "";
|
|
263
|
+
const fileName = withoutQueryOrFragment.split(/[\\/]/).pop();
|
|
264
|
+
return fileName || undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isRetryableWhatsAppOutboundError(error: unknown): boolean {
|
|
269
|
+
return /closed|reset|timed\s*out|disconnect/i.test(formatError(error));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function sendWhatsAppOutboundWithRetry<T>(params: {
|
|
273
|
+
send: () => Promise<T>;
|
|
274
|
+
onRetry?: (params: {
|
|
275
|
+
attempt: number;
|
|
276
|
+
maxAttempts: number;
|
|
277
|
+
backoffMs: number;
|
|
278
|
+
error: unknown;
|
|
279
|
+
errorText: string;
|
|
280
|
+
}) => Promise<void> | void;
|
|
281
|
+
maxAttempts?: number;
|
|
282
|
+
}): Promise<T> {
|
|
283
|
+
const maxAttempts = params.maxAttempts ?? 3;
|
|
284
|
+
let lastError: unknown;
|
|
285
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
286
|
+
try {
|
|
287
|
+
return await params.send();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
lastError = error;
|
|
290
|
+
const errorText = formatError(error);
|
|
291
|
+
const isLastAttempt = attempt === maxAttempts;
|
|
292
|
+
if (!isRetryableWhatsAppOutboundError(error) || isLastAttempt) {
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
const backoffMs = 500 * attempt;
|
|
296
|
+
await params.onRetry?.({
|
|
297
|
+
attempt,
|
|
298
|
+
maxAttempts,
|
|
299
|
+
backoffMs,
|
|
300
|
+
error,
|
|
301
|
+
errorText,
|
|
302
|
+
});
|
|
303
|
+
await sleep(backoffMs);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
throw lastError;
|
|
307
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { loadWebMedia } from "autobot/plugin-sdk/web-media";
|
|
2
|
+
|
|
3
|
+
export async function loadOutboundMediaFromUrl(
|
|
4
|
+
mediaUrl: string,
|
|
5
|
+
options: {
|
|
6
|
+
maxBytes?: number;
|
|
7
|
+
mediaAccess?: {
|
|
8
|
+
localRoots?: readonly string[];
|
|
9
|
+
readFile?: (filePath: string) => Promise<Buffer>;
|
|
10
|
+
};
|
|
11
|
+
mediaLocalRoots?: readonly string[];
|
|
12
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
13
|
+
optimizeImages?: boolean;
|
|
14
|
+
} = {},
|
|
15
|
+
) {
|
|
16
|
+
const readFile = options.mediaAccess?.readFile ?? options.mediaReadFile;
|
|
17
|
+
const localRoots =
|
|
18
|
+
options.mediaAccess?.localRoots?.length && options.mediaAccess.localRoots.length > 0
|
|
19
|
+
? options.mediaAccess.localRoots
|
|
20
|
+
: options.mediaLocalRoots && options.mediaLocalRoots.length > 0
|
|
21
|
+
? options.mediaLocalRoots
|
|
22
|
+
: undefined;
|
|
23
|
+
const sharedOptions = {
|
|
24
|
+
...(options.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}),
|
|
25
|
+
...(options.optimizeImages !== undefined ? { optimizeImages: options.optimizeImages } : {}),
|
|
26
|
+
};
|
|
27
|
+
return await loadWebMedia(
|
|
28
|
+
mediaUrl,
|
|
29
|
+
readFile
|
|
30
|
+
? {
|
|
31
|
+
...sharedOptions,
|
|
32
|
+
localRoots: "any",
|
|
33
|
+
readFile,
|
|
34
|
+
hostReadCapability: true,
|
|
35
|
+
}
|
|
36
|
+
: {
|
|
37
|
+
...sharedOptions,
|
|
38
|
+
...(localRoots ? { localRoots } : {}),
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS = ["sendWhatsApp"] as const;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
2
|
+
|
|
3
|
+
export function createWhatsAppPollFixture() {
|
|
4
|
+
const cfg = { marker: "resolved-cfg" } as AutoBotConfig;
|
|
5
|
+
const poll = {
|
|
6
|
+
question: "Lunch?",
|
|
7
|
+
options: ["Pizza", "Sushi"],
|
|
8
|
+
maxSelections: 1,
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
cfg,
|
|
12
|
+
poll,
|
|
13
|
+
to: "+1555",
|
|
14
|
+
accountId: "work",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { WAMessage } from "baileys";
|
|
2
|
+
import { extractText } from "./inbound/extract.js";
|
|
3
|
+
import { createWebSendApi } from "./inbound/send-api.js";
|
|
4
|
+
import { createWaSocket, waitForWaConnection } from "./session.js";
|
|
5
|
+
import { jidToE164 } from "./text-runtime.js";
|
|
6
|
+
|
|
7
|
+
export type WhatsAppQaDriverObservedMessage = {
|
|
8
|
+
fromJid?: string;
|
|
9
|
+
fromPhoneE164?: string | null;
|
|
10
|
+
messageId?: string;
|
|
11
|
+
observedAt: string;
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type WhatsAppQaDriverSession = {
|
|
16
|
+
close: () => Promise<void>;
|
|
17
|
+
getObservedMessages: () => WhatsAppQaDriverObservedMessage[];
|
|
18
|
+
sendText: (to: string, text: string) => Promise<{ messageId?: string }>;
|
|
19
|
+
waitForMessage: (params: {
|
|
20
|
+
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
}) => Promise<WhatsAppQaDriverObservedMessage>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type MessageUpsertEvent = {
|
|
26
|
+
messages?: WAMessage[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Waiter = {
|
|
30
|
+
predicate: (message: WhatsAppQaDriverObservedMessage) => boolean;
|
|
31
|
+
reject: (error: Error) => void;
|
|
32
|
+
resolve: (message: WhatsAppQaDriverObservedMessage) => void;
|
|
33
|
+
timeout: NodeJS.Timeout;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function normalizeObservedMessage(
|
|
37
|
+
message: WAMessage,
|
|
38
|
+
authDir: string,
|
|
39
|
+
): WhatsAppQaDriverObservedMessage | null {
|
|
40
|
+
if (message.key.fromMe) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const text = extractText(message.message ?? undefined);
|
|
44
|
+
if (!text) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const fromJid = message.key.remoteJid ?? undefined;
|
|
48
|
+
return {
|
|
49
|
+
fromJid,
|
|
50
|
+
fromPhoneE164: fromJid ? jidToE164(fromJid, { authDir }) : null,
|
|
51
|
+
messageId: message.key.id ?? undefined,
|
|
52
|
+
observedAt: new Date().toISOString(),
|
|
53
|
+
text,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function closeSocket(sock: Awaited<ReturnType<typeof createWaSocket>>) {
|
|
58
|
+
const maybeEnd = (sock as unknown as { end?: (error?: Error) => void }).end;
|
|
59
|
+
if (typeof maybeEnd === "function") {
|
|
60
|
+
maybeEnd.call(sock);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const maybeClose = (sock.ws as unknown as { close?: () => void } | undefined)?.close;
|
|
64
|
+
if (typeof maybeClose === "function") {
|
|
65
|
+
maybeClose.call(sock.ws);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function startWhatsAppQaDriverSession(params: {
|
|
70
|
+
authDir: string;
|
|
71
|
+
connectionTimeoutMs?: number;
|
|
72
|
+
}): Promise<WhatsAppQaDriverSession> {
|
|
73
|
+
const sock = await createWaSocket(false, false, { authDir: params.authDir });
|
|
74
|
+
const observedMessages: WhatsAppQaDriverObservedMessage[] = [];
|
|
75
|
+
const waiters: Waiter[] = [];
|
|
76
|
+
let closed = false;
|
|
77
|
+
|
|
78
|
+
const removeWaiter = (waiter: Waiter) => {
|
|
79
|
+
const index = waiters.indexOf(waiter);
|
|
80
|
+
if (index >= 0) {
|
|
81
|
+
waiters.splice(index, 1);
|
|
82
|
+
}
|
|
83
|
+
clearTimeout(waiter.timeout);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const observe = (message: WhatsAppQaDriverObservedMessage) => {
|
|
87
|
+
observedMessages.push(message);
|
|
88
|
+
for (const waiter of waiters.slice()) {
|
|
89
|
+
if (!waiter.predicate(message)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
removeWaiter(waiter);
|
|
93
|
+
waiter.resolve(message);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const onMessagesUpsert = (event: MessageUpsertEvent) => {
|
|
98
|
+
for (const rawMessage of event.messages ?? []) {
|
|
99
|
+
const observed = normalizeObservedMessage(rawMessage, params.authDir);
|
|
100
|
+
if (observed) {
|
|
101
|
+
observe(observed);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const removeMessageListener = () => {
|
|
107
|
+
const evWithOff = sock.ev as unknown as {
|
|
108
|
+
off?: (event: string, listener: (event: MessageUpsertEvent) => void) => void;
|
|
109
|
+
};
|
|
110
|
+
evWithOff.off?.("messages.upsert", onMessagesUpsert);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const closeSessionResources = (waiterError?: Error) => {
|
|
114
|
+
if (closed) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
closed = true;
|
|
118
|
+
for (const waiter of waiters.slice()) {
|
|
119
|
+
removeWaiter(waiter);
|
|
120
|
+
if (waiterError) {
|
|
121
|
+
waiter.reject(waiterError);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
removeMessageListener();
|
|
125
|
+
closeSocket(sock);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
sock.ev.on("messages.upsert", onMessagesUpsert);
|
|
129
|
+
let connectionTimeout: NodeJS.Timeout | undefined;
|
|
130
|
+
try {
|
|
131
|
+
await Promise.race([
|
|
132
|
+
waitForWaConnection(sock),
|
|
133
|
+
new Promise<never>((_, reject) => {
|
|
134
|
+
connectionTimeout = setTimeout(
|
|
135
|
+
() => reject(new Error("timed out waiting for WhatsApp QA driver session")),
|
|
136
|
+
params.connectionTimeoutMs ?? 45_000,
|
|
137
|
+
);
|
|
138
|
+
connectionTimeout.unref?.();
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
closeSessionResources(
|
|
143
|
+
error instanceof Error ? error : new Error("failed starting WhatsApp QA driver session"),
|
|
144
|
+
);
|
|
145
|
+
throw error;
|
|
146
|
+
} finally {
|
|
147
|
+
if (connectionTimeout) {
|
|
148
|
+
clearTimeout(connectionTimeout);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sendApi = createWebSendApi({
|
|
153
|
+
sock,
|
|
154
|
+
defaultAccountId: "qa-driver",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
async close() {
|
|
159
|
+
closeSessionResources(new Error("WhatsApp QA driver session closed"));
|
|
160
|
+
},
|
|
161
|
+
getObservedMessages() {
|
|
162
|
+
return [...observedMessages];
|
|
163
|
+
},
|
|
164
|
+
async sendText(to, text) {
|
|
165
|
+
const result = await sendApi.sendMessage(to, text);
|
|
166
|
+
return {
|
|
167
|
+
messageId: result.messageId,
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async waitForMessage(params) {
|
|
171
|
+
const existing = observedMessages.find(params.match);
|
|
172
|
+
if (existing) {
|
|
173
|
+
return existing;
|
|
174
|
+
}
|
|
175
|
+
return await new Promise<WhatsAppQaDriverObservedMessage>((resolve, reject) => {
|
|
176
|
+
const waiter: Waiter = {
|
|
177
|
+
predicate: params.match,
|
|
178
|
+
resolve,
|
|
179
|
+
reject,
|
|
180
|
+
timeout: setTimeout(() => {
|
|
181
|
+
removeWaiter(waiter);
|
|
182
|
+
reject(new Error("timed out waiting for WhatsApp QA driver message"));
|
|
183
|
+
}, params.timeoutMs),
|
|
184
|
+
};
|
|
185
|
+
waiters.push(waiter);
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/qr-image.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderQrPngBase64, renderQrPngDataUrl } from "autobot/plugin-sdk/media-runtime";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderQrTerminal } from "autobot/plugin-sdk/media-runtime";
|