feat(whatsapp): expand live QA coverage (#90480)

* feat(whatsapp): expand qa driver message support

* feat(qa-lab): add deterministic whatsapp mock replies

* feat(qa-lab): expand whatsapp live qa scenarios

* docs(qa): document whatsapp live qa coverage
This commit is contained in:
Marcus Castro
2026-06-08 00:03:23 -03:00
committed by GitHub
parent 4780546c12
commit 181238fb53
18 changed files with 4525 additions and 236 deletions
+61 -29
View File
@@ -48,6 +48,7 @@ script aliases; both forms are supported.
| `qa telegram` | Live transport lane against a real private Telegram group. |
| `qa discord` | Live transport lane against a real private Discord guild channel. |
| `qa slack` | Live transport lane against a real private Slack channel. |
| `qa whatsapp` | Live transport lane against real WhatsApp Web accounts. |
| `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis) and [Mantis Slack Desktop Runbook](/concepts/mantis-slack-desktop-runbook). |
## Operator flow
@@ -168,15 +169,16 @@ decision still comes from the Discord REST oracle.
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard.
For transport-real Telegram, Discord, and Slack smoke lanes:
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
```bash
pnpm openclaw qa telegram
pnpm openclaw qa discord
pnpm openclaw qa slack
pnpm openclaw qa whatsapp
```
They target a pre-existing real channel with two bots (driver + SUT). Required env vars, scenario lists, output artifacts, and the Convex credential pool are documented in [Telegram, Discord, and Slack QA reference](#telegram-discord-and-slack-qa-reference) below.
They target a pre-existing real channel with two bots or accounts (driver + SUT). Required env vars, scenario lists, output artifacts, and the Convex credential pool are documented in [Telegram, Discord, Slack, and WhatsApp QA reference](#telegram-discord-slack-and-whatsapp-qa-reference) below.
For a full Slack desktop VM run with VNC rescue, run:
@@ -276,10 +278,10 @@ coverage helpers, and scenario-selection helper from
| Telegram | x | x | x | | | | | | | x | |
| Discord | x | x | x | | | | | | | | x |
| Slack | x | x | x | x | x | x | x | x | | | |
| WhatsApp | x | x | | x | x | x | | | x | x | |
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
Telegram, and future live transports share one explicit transport-contract
checklist.
Telegram, and other live transports share one explicit transport-contract checklist.
For a disposable Linux VM lane without bringing Docker into the QA path, run:
@@ -308,25 +310,25 @@ guest: env-based provider keys, the QA live provider config path, and
`CODEX_HOME` when present. Keep `--output-dir` under the repo root so the guest
can write back through the mounted workspace.
## Telegram, Discord, and Slack QA reference
## Telegram, Discord, Slack, and WhatsApp QA reference
Matrix has a [dedicated page](/concepts/qa-matrix) because of its scenario count and Docker-backed homeserver provisioning. Telegram, Discord, and Slack are smaller - a handful of scenarios each, no profile system, against pre-existing real channels - so their reference lives here.
Matrix has a [dedicated page](/concepts/qa-matrix) because of its scenario count and Docker-backed homeserver provisioning. Telegram, Discord, Slack, and WhatsApp run against pre-existing real transports, so their reference lives here.
### Shared CLI flags
These lanes register through `extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts` and accept the same flags:
| Flag | Default | Description |
| ------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `--scenario <id>` | - | Run only this scenario. Repeatable. |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/{telegram,discord,slack}-<timestamp>` | Where reports/summary/observed messages and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral cwd. |
| `--sut-account <id>` | `sut` | Temporary account id inside the QA gateway config. |
| `--provider-mode <mode>` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). |
| `--model <ref>` / `--alt-model <ref>` | provider default | Primary/alternate model refs. |
| `--fast` | off | Provider fast mode where supported. |
| `--credential-source <env\|convex>` | `env` | See [Convex credential pool](#convex-credential-pool). |
| `--credential-role <maintainer\|ci>` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. |
| Flag | Default | Description |
| ------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `--scenario <id>` | - | Run only this scenario. Repeatable. |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/<transport>-<timestamp>` | Where reports/summary/observed messages and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral cwd. |
| `--sut-account <id>` | `sut` | Temporary account id inside the QA gateway config. |
| `--provider-mode <mode>` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). |
| `--model <ref>` / `--alt-model <ref>` | provider default | Primary/alternate model refs. |
| `--fast` | off | Provider fast mode where supported. |
| `--credential-source <env\|convex>` | `env` | See [Convex credential pool](#convex-credential-pool). |
| `--credential-role <maintainer\|ci>` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. |
Each lane exits non-zero on any failed scenario. `--allow-failures` writes artifacts without setting a failing exit code.
@@ -688,22 +690,52 @@ Required env when `--credential-source env`:
Optional:
- `OPENCLAW_QA_WHATSAPP_GROUP_JID` enables `whatsapp-mention-gating`.
- `OPENCLAW_QA_WHATSAPP_GROUP_JID` enables group scenarios such as
`whatsapp-mention-gating` and `whatsapp-group-allowlist-block`.
- `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1` keeps message bodies in
observed-message artifacts.
Scenarios (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts`):
Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts`):
- `whatsapp-canary`
- `whatsapp-pairing-block`
- `whatsapp-mention-gating`
- `whatsapp-approval-exec-native` - opt-in native WhatsApp exec approval
scenario. Requests an exec approval through the gateway, verifies the
WhatsApp message has native reaction approval affordances, resolves it, and
verifies the resolved WhatsApp follow-up.
- `whatsapp-approval-plugin-native` - opt-in native WhatsApp plugin approval
scenario. Enables exec and plugin approval forwarding together, then verifies
the same pending/resolved native WhatsApp path.
- Baseline and group gating: `whatsapp-canary`, `whatsapp-pairing-block`,
`whatsapp-mention-gating`, `whatsapp-top-level-reply-shape`,
`whatsapp-restart-resume`, `whatsapp-group-allowlist-block`.
- Native commands: `whatsapp-help-command`, `whatsapp-status-command`,
`whatsapp-commands-command`, `whatsapp-tools-compact-command`,
`whatsapp-whoami-command`, `whatsapp-context-command`,
`whatsapp-native-new-command`.
- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`,
`whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`,
`whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`.
- Inbound media and structured messages: `whatsapp-inbound-image-caption`,
`whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`,
`whatsapp-group-audio-gating`. These send real WhatsApp image, audio,
document, location, contact, and sticker events through the driver.
- Outbound Gateway and message action coverage:
`whatsapp-outbound-media-matrix`,
`whatsapp-outbound-document-preserves-filename`, `whatsapp-outbound-poll`,
`whatsapp-message-actions`.
- Access-control coverage: `whatsapp-access-control-dm-open`,
`whatsapp-access-control-dm-disabled`, `whatsapp-access-control-group-open`,
`whatsapp-access-control-group-disabled`, `whatsapp-group-allowlist-block`.
- Native approvals: `whatsapp-approval-exec-deny-native`,
`whatsapp-approval-exec-native`, `whatsapp-approval-exec-reaction-native`,
`whatsapp-approval-plugin-native`.
- Status reactions: `whatsapp-status-reactions`.
The catalog currently contains 35 scenarios. The `live-frontier` default lane is
kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default
lane runs 29 deterministic scenarios through the real WhatsApp transport while
mocking only model output. Approval scenarios and a few heavier/blocking checks
remain explicit by scenario id.
The WhatsApp QA driver observes structured live events (`text`, `media`,
`location`, `reaction`, and `poll`) and can actively send media, polls,
contacts, locations, and stickers. QA Lab imports that driver through the
`@openclaw/whatsapp/api.js` package surface instead of reaching into private
WhatsApp runtime files. Message content is redacted by default. Outbound
poll and upload-file coverage run through deterministic gateway `poll` and
`message.action` calls instead of model-prompt-only tool invocation.
Output artifacts:
@@ -1,8 +1,14 @@
// Qa Lab tests cover live artifacts plugin behavior.
import { describe, expect, it } from "vitest";
import { redactQaLiveLaneIssues } from "./live-artifacts.js";
import { redactQaLiveLaneDetails, redactQaLiveLaneIssues } from "./live-artifacts.js";
describe("live transport artifacts", () => {
it("uses a stable public metadata redaction marker", () => {
expect(redactQaLiveLaneDetails()).toBe(
"details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)",
);
});
it("preserves cleanup phase labels while redacting details", () => {
expect(
redactQaLiveLaneIssues([
@@ -14,4 +20,17 @@ describe("live transport artifacts", () => {
"live gateway cleanup: details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)",
]);
});
it("redacts multi-line artifact errors without preserving later section labels", () => {
expect(
redactQaLiveLaneIssues([
[
"WhatsApp QA failed before scenario completion.",
"raw startup error with +15550000002",
"Artifacts:",
"- gatewayDebug: /tmp/openclaw-whatsapp-qa/gateway-debug",
].join("\n"),
]),
).toEqual(["details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)"]);
});
});
@@ -4,17 +4,20 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
const REDACTED_QA_LIVE_LANE_ISSUE_DETAILS =
"details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)";
export function redactQaLiveLaneDetails() {
return REDACTED_QA_LIVE_LANE_ISSUE_DETAILS;
}
export function appendQaLiveLaneIssue(issues: string[], label: string, error: unknown) {
issues.push(`${label}: ${formatErrorMessage(error)}`);
}
export function redactQaLiveLaneIssues(issues: readonly string[]) {
return issues.map((issue) => {
const separatorIndex = issue.indexOf(":");
const label = separatorIndex < 0 ? "" : issue.slice(0, separatorIndex).trim();
return label
? `${label}: ${REDACTED_QA_LIVE_LANE_ISSUE_DETAILS}`
: REDACTED_QA_LIVE_LANE_ISSUE_DETAILS;
const firstLine = issue.split(/\r?\n/u, 1)[0] ?? "";
const separatorIndex = firstLine.indexOf(":");
const label = separatorIndex < 0 ? "" : firstLine.slice(0, separatorIndex).trim();
return label ? `${label}: ${redactQaLiveLaneDetails()}` : redactQaLiveLaneDetails();
});
}
@@ -108,9 +108,16 @@ describe("live transport scenario helpers", () => {
standardId: "thread-follow-up",
scenarioId: "slack-thread-follow-up",
});
expect(lanes.find((lane) => lane.transportId === "whatsapp")?.members).toContainEqual({
standardId: "allowlist-block",
scenarioId: "whatsapp-group-allowlist-block",
});
expect(
lanes.find((lane) => lane.transportId === "discord")?.baselineMissingStandardScenarioIds,
).toEqual(["allowlist-block", "top-level-reply-shape", "restart-resume"]);
expect(
lanes.find((lane) => lane.transportId === "whatsapp")?.baselineMissingStandardScenarioIds,
).toEqual([]);
});
it("keeps coverage report lane summaries aligned with runtime lanes", () => {
@@ -72,8 +72,12 @@ export const LIVE_TRANSPORT_COVERAGE_LANES: readonly LiveTransportCoverageLane[]
commandName: "whatsapp",
members: [
{ standardId: "canary", scenarioId: "whatsapp-canary" },
{ standardId: "allowlist-block", scenarioId: "whatsapp-pairing-block" },
{ standardId: "mention-gating", scenarioId: "whatsapp-mention-gating" },
{ standardId: "top-level-reply-shape", scenarioId: "whatsapp-top-level-reply-shape" },
{ standardId: "restart-resume", scenarioId: "whatsapp-restart-resume" },
{ standardId: "help-command", scenarioId: "whatsapp-help-command" },
{ standardId: "reaction-observation", scenarioId: "whatsapp-status-reactions" },
{ standardId: "allowlist-block", scenarioId: "whatsapp-group-allowlist-block" },
],
},
] as const;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -293,6 +293,26 @@ describe("qa mock openai server", () => {
expect(telegramLongBody).toContain("TELEGRAM-LONG-FINAL-END");
expect(telegramLongBody.length).toBeGreaterThan(4_500);
const whatsappLongResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: true,
input: [
makeUserInput("WhatsApp long final QA check. Use the scripted long final response."),
],
}),
});
expect(whatsappLongResponse.status).toBe(200);
const whatsappLongBody = await whatsappLongResponse.text();
expect(whatsappLongBody).toContain('"type":"response.output_text.delta"');
expect(whatsappLongBody).toContain('"phase":"final_answer"');
expect(whatsappLongBody).toContain("WHATSAPP-LONG-FINAL-BEGIN");
expect(whatsappLongBody).toContain("WHATSAPP-LONG-FINAL-END");
expect(whatsappLongBody.length).toBeGreaterThan(6_000);
const telegramThreeChunkLongResponse = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
@@ -2856,6 +2876,172 @@ describe("qa mock openai server", () => {
expect(outputText(await response.json())).toBe("QA_CANARY_TEST");
});
it("uses WhatsApp location markers only for the matching coordinate body", async () => {
const server = await startMockServer();
const setupInput = makeUserInput(
"When a later WhatsApp location message shows 37.774900, -122.419400, " +
"reply with only this WhatsApp location marker: QA_WHATSAPP_LOCATION_OK. " +
"Reply with only this exact marker: QA_INITIAL_OK",
);
const setupResponse = await postResponses(server, {
stream: false,
input: [setupInput],
});
const response = await postResponses(server, {
stream: false,
input: [setupInput, makeUserInput("📍 37.774900, -122.419400")],
});
expect(setupResponse.status).toBe(200);
expect(outputText(await setupResponse.json())).toBe("QA_INITIAL_OK");
expect(response.status).toBe(200);
expect(outputText(await response.json())).toBe("QA_WHATSAPP_LOCATION_OK");
});
it("uses WhatsApp contact and sticker markers only for matching structured bodies", async () => {
const server = await startMockServer();
const setupInput = makeUserInput(
"When a later WhatsApp contact message appears, " +
"reply with only this WhatsApp contact marker: QA_WHATSAPP_CONTACT_OK. " +
"When a later WhatsApp sticker message appears, " +
"reply with only this WhatsApp sticker marker: QA_WHATSAPP_STICKER_OK. " +
"Reply with only this exact marker: QA_STRUCTURED_INITIAL_OK",
);
const setupResponse = await postResponses(server, {
stream: false,
input: [setupInput],
});
const contactResponse = await postResponses(server, {
stream: false,
input: [setupInput, makeUserInput("<contact>")],
});
const stickerResponse = await postResponses(server, {
stream: false,
input: [setupInput, makeUserInput("<media:sticker>")],
});
expect(setupResponse.status).toBe(200);
expect(outputText(await setupResponse.json())).toBe("QA_STRUCTURED_INITIAL_OK");
expect(contactResponse.status).toBe(200);
expect(outputText(await contactResponse.json())).toBe("QA_WHATSAPP_CONTACT_OK");
expect(stickerResponse.status).toBe(200);
expect(outputText(await stickerResponse.json())).toBe("QA_WHATSAPP_STICKER_OK");
});
it("uses WhatsApp structured markers for channel-prefixed message bodies", async () => {
const server = await startMockServer();
const setupInput = makeUserInput(
"When a later WhatsApp location message shows 37.774900, -122.419400, " +
"reply with only this WhatsApp location marker: QA_WHATSAPP_LOCATION_OK. " +
"When a later WhatsApp contact message appears, " +
"reply with only this WhatsApp contact marker: QA_WHATSAPP_CONTACT_OK. " +
"When a later WhatsApp sticker message appears, " +
"reply with only this WhatsApp sticker marker: QA_WHATSAPP_STICKER_OK. " +
"Reply with only this exact marker: QA_STRUCTURED_INITIAL_OK",
);
const previousExactMarkerInput = makeUserInput(
"Reply with only this previous unrelated exact marker: QA_WHATSAPP_PREVIOUS_OK",
);
const locationResponse = await postResponses(server, {
stream: false,
input: [
setupInput,
previousExactMarkerInput,
makeUserInput(
[
"Conversation info (untrusted metadata):",
"```json",
'{"inbound_event_kind":"user_request"}',
"```",
"",
"📍 37.774900, -122.419400",
].join("\n"),
),
],
});
const contactResponse = await postResponses(server, {
stream: false,
input: [
setupInput,
previousExactMarkerInput,
makeUserInput(
["Sender (untrusted metadata):", "```json", '{"name":"QA"}', "```", "", "<contact>"].join(
"\n",
),
),
],
});
const stickerResponse = await postResponses(server, {
stream: false,
input: [
setupInput,
previousExactMarkerInput,
makeUserInput(
[
"Conversation info (untrusted metadata):",
"```json",
'{"inbound_event_kind":"user_request"}',
"```",
"",
"<media:sticker>",
].join("\n"),
),
],
});
expect(locationResponse.status).toBe(200);
expect(outputText(await locationResponse.json())).toBe("QA_WHATSAPP_LOCATION_OK");
expect(contactResponse.status).toBe(200);
expect(outputText(await contactResponse.json())).toBe("QA_WHATSAPP_CONTACT_OK");
expect(stickerResponse.status).toBe(200);
expect(outputText(await stickerResponse.json())).toBe("QA_WHATSAPP_STICKER_OK");
});
it("streams WhatsApp location markers for the matching coordinate body", async () => {
const server = await startMockServer();
const body = await expectResponsesText(server, {
stream: true,
input: [
makeUserInput(
"When a later WhatsApp location message shows 37.774900, -122.419400, " +
"reply with only this WhatsApp location marker: QA_WHATSAPP_LOCATION_STREAM_OK. " +
"Reply with only this exact marker: QA_INITIAL_STREAM_OK",
),
makeUserInput("📍 37.774900, -122.419400"),
],
});
expect(body).toContain("QA_WHATSAPP_LOCATION_STREAM_OK");
expect(body).not.toContain("QA_INITIAL_STREAM_OK");
});
it("streams WhatsApp structured markers ahead of previous exact markers", async () => {
const server = await startMockServer();
const body = await expectResponsesText(server, {
stream: true,
input: [
makeUserInput(
"When a later WhatsApp location message shows 37.774900, -122.419400, " +
"reply with only this WhatsApp location marker: QA_WHATSAPP_LOCATION_STREAM_OK. " +
"Reply with only this exact marker: QA_INITIAL_STREAM_OK",
),
makeUserInput(
"Reply with only this previous unrelated exact marker: QA_WHATSAPP_PREVIOUS_STREAM_OK",
),
makeUserInput("📍 37.774900, -122.419400"),
],
});
expect(body).toContain("QA_WHATSAPP_LOCATION_STREAM_OK");
expect(body).not.toContain("QA_WHATSAPP_PREVIOUS_STREAM_OK");
});
it("uses image generation directives from request context when the latest user text is generic", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
@@ -3482,6 +3668,66 @@ describe("qa mock openai server", () => {
const ids = body.data.map((entry) => entry.id);
expect(ids).toContain("claude-opus-4-8");
expect(ids).toContain("gpt-5.5");
expect(ids).toContain("gpt-4o-transcribe");
});
it("serves deterministic OpenAI-compatible audio transcription responses", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
const response = await fetch(`${server.baseUrl}/v1/audio/transcriptions`, {
method: "POST",
headers: {
"content-type": "multipart/form-data; boundary=qa",
},
body: "--qa\r\n--qa--\r\n",
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
text: "Reply with only this exact marker: WHATSAPP_QA_AUDIO_TRANSCRIPT_OK",
});
});
it("serves deterministic WhatsApp group audio transcription for large audio uploads", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
const triggered = await fetch(`${server.baseUrl}/v1/audio/transcriptions`, {
method: "POST",
headers: {
"content-type": "multipart/form-data; boundary=qa",
},
body: `--qa\r\ncontent-disposition: form-data; name="file"; filename="upload.wav"\r\n\r\n${"x".repeat(
64_000,
)}\r\n--qa--\r\n`,
});
const quiet = await fetch(`${server.baseUrl}/v1/audio/transcriptions`, {
method: "POST",
headers: {
"content-type": "multipart/form-data; boundary=qa",
},
body: '--qa\r\ncontent-disposition: form-data; name="file"; filename="upload.wav"\r\n\r\nx\r\n--qa--\r\n',
});
expect(triggered.status).toBe(200);
await expect(triggered.json()).resolves.toEqual({
text: "openclawqa reply with only this exact marker after group audio preflight: WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_OK",
});
expect(quiet.status).toBe(200);
await expect(quiet.json()).resolves.toEqual({
text: "Reply with only this exact marker: WHATSAPP_QA_AUDIO_TRANSCRIPT_OK",
});
});
it("dispatches an Anthropic /v1/messages read tool call for source discovery prompts", async () => {
@@ -164,6 +164,7 @@ const QA_TELEGRAM_CURRENT_SESSION_STATUS_PROMPT_RE = /telegram current session_s
const QA_TELEGRAM_STREAM_SINGLE_MARKER = "QA-TELEGRAM-STREAM-SINGLE-OK";
const QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE = /telegram long final three chunk qa check/i;
const QA_TELEGRAM_LONG_FINAL_PROMPT_RE = /telegram long final qa check/i;
const QA_WHATSAPP_LONG_FINAL_PROMPT_RE = /whatsapp long final qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
@@ -180,6 +181,11 @@ const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small projec
const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i;
const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i;
const QA_AUDIO_TRANSCRIPTION_TEXT =
"Reply with only this exact marker: WHATSAPP_QA_AUDIO_TRANSCRIPT_OK";
const QA_GROUP_AUDIO_TRANSCRIPTION_TEXT =
"openclawqa reply with only this exact marker after group audio preflight: WHATSAPP_QA_GROUP_AUDIO_TRANSCRIPT_OK";
const QA_GROUP_AUDIO_MIN_MULTIPART_BODY_CHARS = 48_000;
const QA_MCP_CODE_MODE_API_FILE_PROMPT_RE = /mcp code mode api file qa check/i;
type MockScenarioState = {
@@ -219,6 +225,13 @@ function readBody(req: IncomingMessage): Promise<string> {
});
}
function transcriptionTextForAudioRequest(rawBody: string) {
if (rawBody.length >= QA_GROUP_AUDIO_MIN_MULTIPART_BODY_CHARS) {
return QA_GROUP_AUDIO_TRANSCRIPTION_TEXT;
}
return QA_AUDIO_TRANSCRIPTION_TEXT;
}
function writeJson(res: ServerResponse, status: number, body: unknown) {
const text = JSON.stringify(body);
res.writeHead(status, {
@@ -955,6 +968,33 @@ function extractExactMarkerDirective(text: string) {
);
}
function extractWhatsAppLocationMarkerDirective(text: string) {
return extractLastCapture(
text,
/WhatsApp location marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i,
);
}
function extractWhatsAppContactMarkerDirective(text: string) {
return extractLastCapture(text, /WhatsApp contact marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i);
}
function extractWhatsAppStickerMarkerDirective(text: string) {
return extractLastCapture(text, /WhatsApp sticker marker:\s*([^\s`.,;:!?]+(?:-[^\s`.,;:!?]+)*)/i);
}
function shouldUseWhatsAppLocationMarker(prompt: string) {
return /(?:^|[\n:]\s*)📍\s*37\.774900,\s*-122\.419400\b/u.test(prompt.trim());
}
function shouldUseWhatsAppContactMarker(prompt: string) {
return /(?:^|[\n:]\s*)<contacts?(?::|>)/iu.test(prompt.trim());
}
function shouldUseWhatsAppStickerMarker(prompt: string) {
return /(?:^|[\n:]\s*)<media:sticker>(?:\s|$)/iu.test(prompt.trim());
}
function extractLabeledMarkerDirective(text: string, label: string) {
const escapedLabel = escapeRegExp(label);
const backtickedMatch = extractLastCapture(
@@ -1201,6 +1241,15 @@ function buildAssistantText(
const exactReplyDirective = promptExactReplyDirective ?? extractExactReplyDirective(allInputText);
const exactMarkerDirective =
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
const whatsAppLocationMarker = shouldUseWhatsAppLocationMarker(prompt)
? extractWhatsAppLocationMarkerDirective(allInputText)
: "";
const whatsAppContactMarker = shouldUseWhatsAppContactMarker(prompt)
? extractWhatsAppContactMarkerDirective(allInputText)
: "";
const whatsAppStickerMarker = shouldUseWhatsAppStickerMarker(prompt)
? extractWhatsAppStickerMarkerDirective(allInputText)
: "";
const finishExactlyDirective =
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
const latestImageUserTurn = extractLatestImageUserTurn(input);
@@ -1242,6 +1291,15 @@ function buildAssistantText(
) {
return "Protocol note: the attached image is split horizontally, with red on top and blue on the bottom.";
}
if (whatsAppLocationMarker) {
return whatsAppLocationMarker;
}
if (whatsAppContactMarker) {
return whatsAppContactMarker;
}
if (whatsAppStickerMarker) {
return whatsAppStickerMarker;
}
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
return exactReplyDirective;
}
@@ -1566,19 +1624,20 @@ function splitMockStreamingText(text: string, parts = 3) {
return chunks.length > 1 ? chunks : [text.slice(0, 1), text.slice(1)];
}
function buildTelegramLongFinalText({
function buildQaLongFinalText({
endMarker = "TELEGRAM-LONG-FINAL-END",
segmentPrefix = "telegram-long-final-segment",
segmentCount = 42,
startMarker = "TELEGRAM-LONG-FINAL-BEGIN",
}: {
endMarker?: string;
segmentPrefix?: string;
segmentCount?: number;
startMarker?: string;
} = {}) {
const body = Array.from(
{ length: segmentCount },
(_, index) =>
`telegram-long-final-segment-${String(index + 1).padStart(3, "0")} ${"x".repeat(54)}`,
(_, index) => `${segmentPrefix}-${String(index + 1).padStart(3, "0")} ${"x".repeat(54)}`,
).join("\n");
return `${startMarker}\n${body}\n${endMarker}`;
}
@@ -1850,6 +1909,15 @@ async function buildResponsesPayload(
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
const exactMarkerDirective =
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
const whatsAppLocationMarker = shouldUseWhatsAppLocationMarker(prompt)
? extractWhatsAppLocationMarkerDirective(allInputText)
: "";
const whatsAppContactMarker = shouldUseWhatsAppContactMarker(prompt)
? extractWhatsAppContactMarkerDirective(allInputText)
: "";
const whatsAppStickerMarker = shouldUseWhatsAppStickerMarker(prompt)
? extractWhatsAppStickerMarkerDirective(allInputText)
: "";
const blockStreamingPrompt =
extractLastMatchingUserText(extractAllUserTexts(input), QA_BLOCK_STREAMING_PROMPT_RE) ||
prompt ||
@@ -2073,7 +2141,7 @@ async function buildResponsesPayload(
return buildAssistantEvents("");
}
if (QA_TELEGRAM_LONG_FINAL_THREE_CHUNK_PROMPT_RE.test(allInputText)) {
const text = buildTelegramLongFinalText({
const text = buildQaLongFinalText({
endMarker: "TELEGRAM-LONG-FINAL-3CHUNK-END",
segmentCount: 96,
startMarker: "TELEGRAM-LONG-FINAL-3CHUNK-BEGIN",
@@ -2088,7 +2156,7 @@ async function buildResponsesPayload(
]);
}
if (QA_TELEGRAM_LONG_FINAL_PROMPT_RE.test(allInputText)) {
const text = buildTelegramLongFinalText();
const text = buildQaLongFinalText();
return buildAssistantEvents([
{
id: "msg_mock_telegram_long_final",
@@ -2098,6 +2166,22 @@ async function buildResponsesPayload(
},
]);
}
if (QA_WHATSAPP_LONG_FINAL_PROMPT_RE.test(allInputText)) {
const text = buildQaLongFinalText({
endMarker: "WHATSAPP-LONG-FINAL-END",
segmentPrefix: "whatsapp-long-final-segment",
segmentCount: 64,
startMarker: "WHATSAPP-LONG-FINAL-BEGIN",
});
return buildAssistantEvents([
{
id: "msg_mock_whatsapp_long_final",
phase: "final_answer",
streamDeltas: splitMockStreamingText(text),
text,
},
]);
}
if (
QA_STREAMING_PROMPT_RE.test(allInputText) &&
allInputText.includes(QA_TELEGRAM_STREAM_SINGLE_MARKER)
@@ -2180,6 +2264,15 @@ async function buildResponsesPayload(
exactMarkerDirective ?? exactReplyDirective ?? "QA-GROUP-FALLBACK-OK",
);
}
if (whatsAppLocationMarker) {
return buildAssistantEvents(whatsAppLocationMarker);
}
if (whatsAppContactMarker) {
return buildAssistantEvents(whatsAppContactMarker);
}
if (whatsAppStickerMarker) {
return buildAssistantEvents(whatsAppStickerMarker);
}
if (/\bmarker\b/i.test(prompt) && exactReplyDirective) {
return buildAssistantEvents(exactReplyDirective);
}
@@ -3196,6 +3289,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
{ id: "gpt-5.5", object: "model" },
{ id: "gpt-5.5-alt", object: "model" },
{ id: "gpt-image-1", object: "model" },
{ id: "gpt-4o-transcribe", object: "model" },
{ id: "text-embedding-3-small", object: "model" },
{ id: "claude-opus-4-8", object: "model" },
{ id: "claude-sonnet-4-6", object: "model" },
@@ -3232,6 +3326,13 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/audio/transcriptions") {
const raw = await readBody(req);
writeJson(res, 200, {
text: transcriptionTextForAudioRequest(raw),
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
@@ -30,6 +30,9 @@ type MockWebListener = {
signalClose: () => void;
sendMessage: () => Promise<WhatsAppSendResult>;
sendPoll: () => Promise<WhatsAppSendResult>;
sendContact: () => Promise<WhatsAppSendResult>;
sendLocation: () => Promise<WhatsAppSendResult>;
sendSticker: () => Promise<WhatsAppSendResult>;
sendReaction: () => Promise<WhatsAppSendResult>;
sendComposingTo: () => Promise<void>;
};
@@ -264,6 +267,9 @@ export function createMockWebListener(): MockWebListener {
signalClose: vi.fn(),
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
sendContact: vi.fn(async () => createAcceptedWhatsAppSendResult("contact", "contact-1")),
sendLocation: vi.fn(async () => createAcceptedWhatsAppSendResult("location", "location-1")),
sendSticker: vi.fn(async () => createAcceptedWhatsAppSendResult("sticker", "sticker-1")),
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
sendComposingTo: vi.fn(async () => undefined),
};
@@ -0,0 +1,33 @@
// Whatsapp plugin module implements inbound media MIME normalization.
import type { proto } from "baileys";
/**
* Resolve the MIME type for an inbound media message.
* Falls back to WhatsApp's standard formats when Baileys omits the MIME.
*/
export function resolveInboundMediaMimetype(message: proto.IMessage): string | undefined {
const explicit =
message.imageMessage?.mimetype ??
message.videoMessage?.mimetype ??
message.documentMessage?.mimetype ??
message.audioMessage?.mimetype ??
message.stickerMessage?.mimetype ??
undefined;
if (explicit) {
return explicit;
}
// WhatsApp voice messages (PTT) and audio use OGG Opus by default.
if (message.audioMessage) {
return "audio/ogg; codecs=opus";
}
if (message.imageMessage) {
return "image/jpeg";
}
if (message.videoMessage) {
return "video/mp4";
}
if (message.stickerMessage) {
return "image/webp";
}
return undefined;
}
+2 -32
View File
@@ -4,6 +4,7 @@ import { saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-stor
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { createWaSocket } from "../session.js";
import { extractContextInfo } from "./extract.js";
import { resolveInboundMediaMimetype } from "./media-mimetype.js";
import { downloadMediaMessage, normalizeMessageContent } from "./runtime-api.js";
export class WhatsAppInboundMediaLimitExceededError extends Error {
@@ -18,37 +19,6 @@ function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | un
return normalized;
}
/**
* Resolve the MIME type for an inbound media message.
* Falls back to WhatsApp's standard formats when Baileys omits the MIME.
*/
function resolveMediaMimetype(message: proto.IMessage): string | undefined {
const explicit =
message.imageMessage?.mimetype ??
message.videoMessage?.mimetype ??
message.documentMessage?.mimetype ??
message.audioMessage?.mimetype ??
message.stickerMessage?.mimetype ??
undefined;
if (explicit) {
return explicit;
}
// WhatsApp voice messages (PTT) and audio use OGG Opus by default
if (message.audioMessage) {
return "audio/ogg; codecs=opus";
}
if (message.imageMessage) {
return "image/jpeg";
}
if (message.videoMessage) {
return "video/mp4";
}
if (message.stickerMessage) {
return "image/webp";
}
return undefined;
}
export async function downloadInboundMedia(
msg: proto.IWebMessageInfo,
sock: Awaited<ReturnType<typeof createWaSocket>>,
@@ -58,7 +28,7 @@ export async function downloadInboundMedia(
if (!message) {
return undefined;
}
const mimetype = resolveMediaMimetype(message);
const mimetype = resolveInboundMediaMimetype(message);
const fileName = message.documentMessage?.fileName ?? undefined;
if (
!message.imageMessage &&
+4 -2
View File
@@ -1289,8 +1289,10 @@ export async function attachWebInboxToSocket(
signalClose: (reason?: WebListenerCloseReason) => {
resolveClose(reason ?? { status: undefined, isLoggedOut: false, error: "closed" });
},
// IPC surface (sendMessage/sendPoll/sendReaction/sendComposingTo)
...sendApi,
sendComposingTo: sendApi.sendComposingTo,
sendMessage: sendApi.sendMessage,
sendPoll: sendApi.sendPoll,
sendReaction: sendApi.sendReaction,
} as const;
}
@@ -7,6 +7,7 @@ import { listMessageReceiptPlatformIds } from "openclaw/plugin-sdk/channel-outbo
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js";
import { createWebSendApi } from "./send-api.js";
import type { WhatsAppSendResult } from "./send-result.js";
const recordChannelActivity = vi.hoisted(() => vi.fn());
const imageOps = vi.hoisted(() => ({
@@ -105,10 +106,7 @@ describe("createWebSendApi", () => {
expectRecordFields(requireSendContent(callIndex), fields);
}
function expectSendResultFields(
result: Awaited<ReturnType<typeof api.sendMessage | typeof api.sendReaction>>,
fields: Record<string, unknown>,
) {
function expectSendResultFields(result: WhatsAppSendResult, fields: Record<string, unknown>) {
expectRecordFields(requireRecord(result, "send result"), fields);
}
@@ -229,6 +227,72 @@ describe("createWebSendApi", () => {
});
});
it("sends structured contact messages through the canonical send path", async () => {
const res = await api.sendContact("+1555", {
displayName: "QA Contact",
vcard: "BEGIN:VCARD\nFN:QA Contact\nEND:VCARD",
});
expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", {
contacts: {
displayName: "QA Contact",
contacts: [
{
displayName: "QA Contact",
vcard: "BEGIN:VCARD\nFN:QA Contact\nEND:VCARD",
},
],
},
});
expectSendResultFields(res, {
kind: "contact",
messageId: "msg-1",
providerAccepted: true,
});
expect(recordChannelActivity).toHaveBeenCalledWith({
channel: "whatsapp",
accountId: "main",
direction: "outbound",
});
});
it("sends structured location messages through the canonical send path", async () => {
const res = await api.sendLocation("+1555", {
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
name: "QA Location",
});
expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", {
location: {
address: undefined,
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
name: "QA Location",
},
});
expectSendResultFields(res, {
kind: "location",
messageId: "msg-1",
providerAccepted: true,
});
});
it("sends structured sticker messages through the canonical send path", async () => {
const payload = Buffer.from("webp");
const res = await api.sendSticker("+1555", payload);
expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", {
sticker: payload,
mimetype: "image/webp",
});
expectSendResultFields(res, {
kind: "sticker",
messageId: "msg-1",
providerAccepted: true,
});
});
it("adds native mention metadata to group text sends", async () => {
api = createWebSendApi({
sock: { sendMessage, sendPresenceUpdate },
+89 -10
View File
@@ -18,10 +18,27 @@ import {
import {
combineWhatsAppSendResults,
normalizeWhatsAppSendResult,
type WhatsAppSendKind,
type WhatsAppSendResult,
} from "./send-result.js";
import type { ActiveWebSendOptions } from "./types.js";
type StructuredContactSend = {
displayName: string;
vcard: string;
};
type StructuredLocationSend = {
address?: string;
degreesLatitude: number;
degreesLongitude: number;
name?: string;
};
type StructuredStickerSendOptions = {
mimetype?: string;
};
function recordWhatsAppOutbound(accountId: string) {
recordChannelActivity({
channel: "whatsapp",
@@ -65,6 +82,16 @@ export function createWebSendApi(params: {
params.resolveOutboundMentions
? await params.resolveOutboundMentions({ jid, text })
: { text, mentionedJids: [] };
const sendStructuredMessage = async (
to: string,
content: AnyMessageContent,
kind: WhatsAppSendKind,
): Promise<WhatsAppSendResult> => {
const jid = resolveOutboundJid(to);
const result = await params.sock.sendMessage(jid, content);
recordWhatsAppOutbound(params.defaultAccountId);
return normalizeWhatsAppSendResult(result, kind);
};
return {
sendMessage: async (
@@ -160,16 +187,68 @@ export function createWebSendApi(params: {
to: string,
poll: { question: string; options: string[]; maxSelections?: number },
): Promise<WhatsAppSendResult> => {
const jid = resolveOutboundJid(to);
const result = await params.sock.sendMessage(jid, {
poll: {
name: poll.question,
values: poll.options,
selectableCount: poll.maxSelections ?? 1,
},
} as AnyMessageContent);
recordWhatsAppOutbound(params.defaultAccountId);
return normalizeWhatsAppSendResult(result, "poll");
return await sendStructuredMessage(
to,
{
poll: {
name: poll.question,
values: poll.options,
selectableCount: poll.maxSelections ?? 1,
},
} as AnyMessageContent,
"poll",
);
},
sendContact: async (
to: string,
contact: StructuredContactSend,
): Promise<WhatsAppSendResult> => {
return await sendStructuredMessage(
to,
{
contacts: {
displayName: contact.displayName,
contacts: [
{
displayName: contact.displayName,
vcard: contact.vcard,
},
],
},
} as AnyMessageContent,
"contact",
);
},
sendLocation: async (
to: string,
location: StructuredLocationSend,
): Promise<WhatsAppSendResult> => {
return await sendStructuredMessage(
to,
{
location: {
degreesLatitude: location.degreesLatitude,
degreesLongitude: location.degreesLongitude,
name: location.name,
address: location.address,
},
} as AnyMessageContent,
"location",
);
},
sendSticker: async (
to: string,
stickerBuffer: Buffer,
options?: StructuredStickerSendOptions,
): Promise<WhatsAppSendResult> => {
return await sendStructuredMessage(
to,
{
sticker: stickerBuffer,
mimetype: options?.mimetype ?? "image/webp",
} as AnyMessageContent,
"sticker",
);
},
sendReaction: async (
chatJid: string,
@@ -9,7 +9,14 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text";
export type WhatsAppSendKind =
| "contact"
| "location"
| "media"
| "poll"
| "reaction"
| "sticker"
| "text";
type WhatsAppSendKey = Omit<
Pick<WAMessageKey, "fromMe" | "id" | "participant" | "remoteJid">,
@@ -7,12 +7,21 @@ import { startWhatsAppQaDriverSession } from "./qa-driver.runtime.js";
const mocks = vi.hoisted(() => ({
createWaSocket: vi.fn(),
jidToE164: vi.fn(),
sendContact: vi.fn(),
sendLocation: vi.fn(),
sendPoll: vi.fn(),
sendReaction: vi.fn(),
sendSticker: vi.fn(),
sendMessage: vi.fn(),
socketSendMessage: vi.fn(),
waitForWaConnection: vi.fn(),
}));
vi.mock("./session.js", () => ({
createWaSocket: mocks.createWaSocket,
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
getStatusCode: (error: unknown) =>
(error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode,
waitForWaConnection: mocks.waitForWaConnection,
}));
@@ -22,7 +31,12 @@ vi.mock("./text-runtime.js", () => ({
vi.mock("./inbound/send-api.js", () => ({
createWebSendApi: () => ({
sendContact: mocks.sendContact,
sendLocation: mocks.sendLocation,
sendMessage: mocks.sendMessage,
sendPoll: mocks.sendPoll,
sendReaction: mocks.sendReaction,
sendSticker: mocks.sendSticker,
}),
}));
@@ -30,17 +44,18 @@ function createMockSocket() {
return {
end: vi.fn(),
ev: new EventEmitter(),
sendMessage: mocks.socketSendMessage,
ws: {
close: vi.fn(),
},
};
}
function incomingMessage(remoteJid: string, text: string): WAMessage {
function incomingMessage(remoteJid: string, text: string, id = "message-1"): WAMessage {
return {
key: {
fromMe: false,
id: "message-1",
id,
remoteJid,
},
message: {
@@ -49,6 +64,165 @@ function incomingMessage(remoteJid: string, text: string): WAMessage {
} as WAMessage;
}
function incomingImageMessage(remoteJid: string, text: string): WAMessage {
return {
key: {
fromMe: false,
id: "image-1",
remoteJid,
},
message: {
imageMessage: {
caption: text,
mimetype: "image/png",
},
},
} as WAMessage;
}
function incomingImageMessageWithoutMime(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "image-no-mime-1",
remoteJid,
},
message: {
imageMessage: {},
},
} as WAMessage;
}
function incomingStickerMessageWithoutMime(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "sticker-no-mime-1",
remoteJid,
},
message: {
stickerMessage: {},
},
} as WAMessage;
}
function incomingAudioMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "audio-1",
remoteJid,
},
message: {
audioMessage: {
mimetype: "audio/ogg; codecs=opus",
},
},
} as WAMessage;
}
function incomingEditedImageMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "edited-image-1",
remoteJid,
},
message: {
editedMessage: {
message: {
imageMessage: {
caption: "edited image caption",
},
},
},
},
} as WAMessage;
}
function incomingLocationMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "location-1",
remoteJid,
},
message: {
locationMessage: {
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
},
},
} as WAMessage;
}
function incomingReactionMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "reaction-1",
remoteJid,
},
message: {
reactionMessage: {
text: "👍",
key: {
fromMe: true,
id: "driver-message-1",
participant: "15551234567@s.whatsapp.net",
},
},
},
} as WAMessage;
}
function incomingQuotedMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "quoted-reply-1",
remoteJid,
},
message: {
extendedTextMessage: {
text: "reply body",
contextInfo: {
participant: "15551234567@s.whatsapp.net",
quotedMessage: {
conversation: "original body",
},
stanzaId: "driver-message-1",
},
},
},
} as WAMessage;
}
function incomingQuotedLocationMessage(remoteJid: string): WAMessage {
return {
key: {
fromMe: false,
id: "quoted-location-reply-1",
remoteJid,
},
message: {
extendedTextMessage: {
text: "reply body",
contextInfo: {
participant: "15551234567@s.whatsapp.net",
quotedMessage: {
locationMessage: {
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
},
},
stanzaId: "driver-location-1",
},
},
},
} as WAMessage;
}
describe("startWhatsAppQaDriverSession", () => {
afterEach(() => {
vi.useRealTimers();
@@ -79,6 +253,7 @@ describe("startWhatsAppQaDriverSession", () => {
{
fromJid: "12345@lid",
fromPhoneE164: "+15551234567",
kind: "text",
messageId: "message-1",
observedAt,
text: "hello",
@@ -88,6 +263,349 @@ describe("startWhatsAppQaDriverSession", () => {
await session.close();
});
it("does not satisfy a wait with messages observed before the lower bound", async () => {
vi.useFakeTimers();
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
vi.setSystemTime(new Date("2026-06-04T23:42:32.036Z"));
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingMessage("12345@lid", "OpenClaw status stale", "stale-message")],
});
const observedAfter = new Date("2026-06-04T23:46:59.166Z");
vi.setSystemTime(observedAfter);
const waited = session.waitForMessage({
observedAfter,
timeoutMs: 1_000,
match: (message) => message.text.includes("OpenClaw status"),
});
vi.setSystemTime(new Date("2026-06-04T23:47:00.000Z"));
sock.ev.emit("messages.upsert", {
messages: [incomingMessage("12345@lid", "OpenClaw status fresh", "fresh-message")],
});
await expect(waited).resolves.toMatchObject({
messageId: "fresh-message",
text: "OpenClaw status fresh",
});
await session.close();
});
it("observes media messages without dropping their caption text", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingImageMessage("12345@lid", "image caption")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
hasMedia: true,
kind: "media",
mediaType: "image/png",
text: "image caption",
});
await session.close();
});
it("observes audio media messages without requiring a text body", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingAudioMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
hasMedia: true,
kind: "media",
mediaType: "audio/ogg; codecs=opus",
text: "",
});
await session.close();
});
it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [
incomingImageMessageWithoutMime("12345@lid"),
incomingStickerMessageWithoutMime("12345@lid"),
],
});
expect(session.getObservedMessages()).toMatchObject([
{
hasMedia: true,
kind: "media",
mediaType: "image/jpeg",
},
{
hasMedia: true,
kind: "media",
mediaType: "image/webp",
},
]);
await session.close();
});
it("observes media through Baileys future-proof wrappers", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingEditedImageMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
hasMedia: true,
kind: "media",
mediaType: "image/jpeg",
text: "edited image caption",
});
await session.close();
});
it("observes top-level location messages with canonical location text", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingLocationMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
kind: "location",
text: "📍 37.774900, -122.419400",
});
await session.close();
});
it("observes reaction messages that have no text body", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingReactionMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
kind: "reaction",
reaction: {
emoji: "👍",
fromMe: true,
messageId: "driver-message-1",
participant: "15551234567@s.whatsapp.net",
},
text: "",
});
await session.close();
});
it("observes quoted reply context", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingQuotedMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
kind: "text",
quoted: {
messageId: "driver-message-1",
participant: "15551234567@s.whatsapp.net",
text: "original body",
},
text: "reply body",
});
await session.close();
});
it("observes quoted location context with canonical reply body text", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.jidToE164.mockReturnValue("+15551234567");
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
sock.ev.emit("messages.upsert", {
messages: [incomingQuotedLocationMessage("12345@lid")],
});
expect(session.getObservedMessages()[0]).toMatchObject({
kind: "text",
quoted: {
messageId: "driver-location-1",
participant: "15551234567@s.whatsapp.net",
text: "📍 37.774900, -122.419400",
},
text: "reply body",
});
await session.close();
});
it("uses the web send API for existing outbound helpers", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.sendMessage.mockResolvedValue({ messageId: "send-1" });
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
mocks.sendReaction.mockResolvedValue({ messageId: "reaction-send-1" });
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
await expect(
session.sendMedia("15551234567", "caption", Buffer.from("png"), "image/png", {
fileName: "qa.png",
}),
).resolves.toEqual({ messageId: "send-1" });
await expect(
session.sendPoll("15551234567", {
question: "Pick one",
options: ["A", "B"],
}),
).resolves.toEqual({ messageId: "poll-1" });
await expect(
session.sendReaction("15551234567@s.whatsapp.net", "driver-message-1", "👍", {
fromMe: true,
}),
).resolves.toEqual({ messageId: "reaction-send-1" });
expect(mocks.sendMessage).toHaveBeenCalledWith(
"15551234567",
"caption",
Buffer.from("png"),
"image/png",
{ fileName: "qa.png" },
);
expect(mocks.sendPoll).toHaveBeenCalledWith("15551234567", {
question: "Pick one",
options: ["A", "B"],
});
expect(mocks.sendReaction).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"driver-message-1",
"👍",
true,
undefined,
);
await session.close();
});
it("sends structured QA stimuli through the web send API", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
mocks.sendContact.mockResolvedValue({ messageId: "contact-1" });
mocks.sendLocation.mockResolvedValue({ messageId: "location-1" });
mocks.sendSticker.mockResolvedValue({ messageId: "sticker-1" });
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
await expect(
session.sendContact("15551234567", {
displayName: "QA Contact",
vcard: "BEGIN:VCARD\nFN:QA Contact\nEND:VCARD",
}),
).resolves.toEqual({ messageId: "contact-1" });
await expect(
session.sendLocation("15551234567", {
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
name: "QA Location",
}),
).resolves.toEqual({ messageId: "location-1" });
await expect(
session.sendSticker("15551234567", Buffer.from("webp"), { mimetype: "image/webp" }),
).resolves.toEqual({ messageId: "sticker-1" });
expect(mocks.sendContact).toHaveBeenCalledWith("15551234567", {
displayName: "QA Contact",
vcard: "BEGIN:VCARD\nFN:QA Contact\nEND:VCARD",
});
expect(mocks.sendLocation).toHaveBeenCalledWith("15551234567", {
degreesLatitude: 37.7749,
degreesLongitude: -122.4194,
name: "QA Location",
});
expect(mocks.sendSticker).toHaveBeenCalledWith("15551234567", Buffer.from("webp"), {
mimetype: "image/webp",
});
expect(mocks.socketSendMessage).not.toHaveBeenCalled();
await session.close();
});
it("passes the connection timeout to the shared connection waiter", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
@@ -103,6 +621,73 @@ describe("startWhatsAppQaDriverSession", () => {
await session.close();
});
it("can wait for pending notifications before returning the driver session", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
const pending = startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
connectionTimeoutMs: 10_000,
waitForPendingNotifications: true,
});
let settled = false;
pending.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await Promise.resolve();
expect(settled).toBe(false);
sock.ev.emit("connection.update", { receivedPendingNotifications: true });
const session = await pending;
expect(settled).toBe(true);
await session.close();
});
it("rejects pending and future waits when the connected driver session closes", async () => {
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
});
const pending = session.waitForMessage({
match: (message) => message.text.includes("approval required"),
timeoutMs: 60_000,
});
sock.ev.emit("connection.update", {
connection: "close",
lastDisconnect: {
date: new Date("2026-06-05T17:54:52.000Z"),
error: {
output: {
statusCode: 428,
},
},
},
});
await expect(pending).rejects.toThrow("WhatsApp QA driver connection closed (status 428)");
await expect(
session.waitForMessage({
match: (message) => message.text.includes("approval required"),
timeoutMs: 60_000,
}),
).rejects.toThrow("WhatsApp QA driver connection closed (status 428)");
expect(sock.ev.listenerCount("messages.upsert")).toBe(0);
expect(sock.ev.listenerCount("connection.update")).toBe(0);
expect(sock.end).toHaveBeenCalledOnce();
});
it("closes the socket and removes listeners when connection setup times out", async () => {
const sock = createMockSocket();
const timeoutError = new Error("timed out waiting for WhatsApp QA driver session");
@@ -118,6 +703,7 @@ describe("startWhatsAppQaDriverSession", () => {
expect(mocks.waitForWaConnection).toHaveBeenCalledWith(sock, { timeoutMs: 10 });
expect(sock.ev.listenerCount("messages.upsert")).toBe(0);
expect(sock.ev.listenerCount("connection.update")).toBe(0);
expect(sock.end).toHaveBeenCalledOnce();
});
});
+403 -12
View File
@@ -1,24 +1,118 @@
// Whatsapp plugin module implements qa driver behavior.
import type { WAMessage } from "baileys";
import { extractText } from "./inbound/extract.js";
import type { ConnectionState, proto, WAMessage } from "baileys";
import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
import {
describeReplyContext,
extractContextInfo,
extractLocationData,
extractText,
} from "./inbound/extract.js";
import { resolveInboundMediaMimetype } from "./inbound/media-mimetype.js";
import { normalizeMessageContent } from "./inbound/runtime-api.js";
import { createWebSendApi } from "./inbound/send-api.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
import type { ActiveWebSendOptions } from "./inbound/types.js";
import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "./session.js";
import { jidToE164 } from "./text-runtime.js";
export type WhatsAppQaDriverObservedMessageKind =
| "media"
| "location"
| "poll"
| "reaction"
| "text"
| "unknown";
export type WhatsAppQaDriverQuotedMessage = {
messageId?: string;
participant?: string;
text?: string;
};
export type WhatsAppQaDriverObservedReaction = {
emoji: string;
fromMe?: boolean;
messageId?: string;
participant?: string;
};
export type WhatsAppQaDriverObservedPoll = {
options: string[];
question?: string;
};
export type WhatsAppQaDriverObservedMessage = {
fromJid?: string;
fromPhoneE164?: string | null;
hasMedia?: boolean;
kind: WhatsAppQaDriverObservedMessageKind;
mediaFileName?: string;
mediaType?: string;
messageId?: string;
observedAt: string;
poll?: WhatsAppQaDriverObservedPoll;
quoted?: WhatsAppQaDriverQuotedMessage;
reaction?: WhatsAppQaDriverObservedReaction;
text: string;
};
export type WhatsAppQaDriverSendTextOptions = Pick<ActiveWebSendOptions, "quotedMessageKey">;
export type WhatsAppQaDriverSendMediaOptions = Pick<
ActiveWebSendOptions,
"asDocument" | "fileName" | "gifPlayback" | "quotedMessageKey"
>;
export type WhatsAppQaDriverSendReactionOptions = {
fromMe: boolean;
participant?: string;
};
export type WhatsAppQaDriverSession = {
close: () => Promise<void>;
getObservedMessages: () => WhatsAppQaDriverObservedMessage[];
sendText: (to: string, text: string) => Promise<{ messageId?: string }>;
sendContact: (
to: string,
contact: { displayName: string; vcard: string },
) => Promise<{ messageId?: string }>;
sendLocation: (
to: string,
location: {
address?: string;
degreesLatitude: number;
degreesLongitude: number;
name?: string;
},
) => Promise<{ messageId?: string }>;
sendMedia: (
to: string,
text: string,
mediaBuffer: Buffer,
mediaType: string,
options?: WhatsAppQaDriverSendMediaOptions,
) => Promise<{ messageId?: string }>;
sendPoll: (
to: string,
poll: { maxSelections?: number; options: string[]; question: string },
) => Promise<{ messageId?: string }>;
sendReaction: (
chatJid: string,
messageId: string,
emoji: string,
options: WhatsAppQaDriverSendReactionOptions,
) => Promise<{ messageId?: string }>;
sendSticker: (
to: string,
stickerBuffer: Buffer,
options?: { mimetype?: string },
) => Promise<{ messageId?: string }>;
sendText: (
to: string,
text: string,
options?: WhatsAppQaDriverSendTextOptions,
) => Promise<{ messageId?: string }>;
waitForMessage: (params: {
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
observedAfter?: Date;
timeoutMs: number;
}) => Promise<WhatsAppQaDriverObservedMessage>;
};
@@ -27,6 +121,8 @@ type MessageUpsertEvent = {
messages?: WAMessage[];
};
type ConnectionUpdateEvent = Partial<ConnectionState>;
type Waiter = {
predicate: (message: WhatsAppQaDriverObservedMessage) => boolean;
reject: (error: Error) => void;
@@ -34,6 +130,148 @@ type Waiter = {
timeout: NodeJS.Timeout;
};
type PendingNotificationsWaiter = {
reject: (error: Error) => void;
resolve: () => void;
timeout: NodeJS.Timeout;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object");
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function findMessageSection(
message: unknown,
sectionNames: readonly string[],
): Record<string, unknown> | undefined {
if (!isRecord(message)) {
return undefined;
}
const queue: Array<{ depth: number; value: Record<string, unknown> }> = [
{ depth: 0, value: message },
];
const seen = new Set<Record<string, unknown>>();
while (queue.length > 0) {
const current = queue.shift();
if (!current || seen.has(current.value)) {
continue;
}
seen.add(current.value);
for (const sectionName of sectionNames) {
const section = current.value[sectionName];
if (isRecord(section)) {
return section;
}
}
if (current.depth >= 4) {
continue;
}
for (const wrapperName of [
"botInvokeMessage",
"documentWithCaptionMessage",
"ephemeralMessage",
"groupMentionedMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
]) {
const wrapper = current.value[wrapperName];
if (isRecord(wrapper) && isRecord(wrapper.message)) {
queue.push({ depth: current.depth + 1, value: wrapper.message });
}
}
}
return undefined;
}
function readReaction(message: unknown): WhatsAppQaDriverObservedReaction | undefined {
const reaction = findMessageSection(message, ["reactionMessage"]);
if (!reaction) {
return undefined;
}
const emoji = readString(reaction.text) ?? "";
const key = isRecord(reaction.key) ? reaction.key : undefined;
return {
emoji,
fromMe: readBoolean(key?.fromMe),
messageId: readString(key?.id),
participant: readString(key?.participant),
};
}
function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined {
const poll = findMessageSection(message, [
"pollCreationMessage",
"pollCreationMessageV2",
"pollCreationMessageV3",
]);
if (!poll) {
return undefined;
}
const rawOptions = Array.isArray(poll.options) ? poll.options : [];
const options = rawOptions
.map((option) => (isRecord(option) ? readString(option.optionName) : undefined))
.filter((option): option is string => Boolean(option));
return {
options,
question: readString(poll.name),
};
}
function readMedia(message: unknown):
| {
fileName?: string;
mediaType?: string;
}
| undefined {
const normalizedMessage = isRecord(message)
? normalizeMessageContent(message as proto.IMessage)
: undefined;
const mediaSections = [
"imageMessage",
"videoMessage",
"audioMessage",
"documentMessage",
"stickerMessage",
];
for (const sectionName of mediaSections) {
const section = findMessageSection(normalizedMessage ?? message, [sectionName]);
if (!section) {
continue;
}
const mediaMessage = { [sectionName]: section } as proto.IMessage;
return {
fileName: readString(section.fileName),
mediaType: resolveInboundMediaMimetype(mediaMessage),
};
}
return undefined;
}
function readQuotedMessage(message: WAMessage): WhatsAppQaDriverQuotedMessage | undefined {
const contextInfo = extractContextInfo(message.message ?? undefined);
const replyContext = describeReplyContext(message.message as proto.IMessage | undefined);
if (!contextInfo && !replyContext) {
return undefined;
}
if (!contextInfo?.stanzaId && !contextInfo?.participant && !replyContext?.body) {
return undefined;
}
return {
messageId: replyContext?.id ?? contextInfo?.stanzaId ?? undefined,
participant: replyContext?.sender?.jid ?? contextInfo?.participant ?? undefined,
text: replyContext?.body,
};
}
function normalizeObservedMessage(
message: WAMessage,
authDir: string,
@@ -41,17 +279,42 @@ function normalizeObservedMessage(
if (message.key.fromMe) {
return null;
}
const text = extractText(message.message ?? undefined);
if (!text) {
const extractedText = extractText(message.message ?? undefined);
const location = extractLocationData(message.message as proto.IMessage | undefined);
const locationText = location ? formatLocationText(location) : undefined;
const text = [extractedText, locationText].filter(Boolean).join("\n").trim() || undefined;
const reaction = readReaction(message.message);
const poll = readPoll(message.message);
const media = readMedia(message.message);
const quoted = readQuotedMessage(message);
const kind: WhatsAppQaDriverObservedMessageKind = reaction
? "reaction"
: poll
? "poll"
: media
? "media"
: location
? "location"
: text
? "text"
: "unknown";
if (!text && kind === "unknown") {
return null;
}
const fromJid = message.key.remoteJid ?? undefined;
return {
fromJid,
fromPhoneE164: fromJid ? jidToE164(fromJid, { authDir }) : null,
hasMedia: media ? true : undefined,
kind,
mediaFileName: media?.fileName,
mediaType: media?.mediaType,
messageId: message.key.id ?? undefined,
observedAt: new Date().toISOString(),
text,
poll,
quoted,
reaction,
text: text ?? "",
};
}
@@ -67,14 +330,26 @@ function closeSocket(sock: Awaited<ReturnType<typeof createWaSocket>>) {
}
}
function createConnectionClosedError(update: ConnectionUpdateEvent) {
const reason = update.lastDisconnect?.error;
const status = getStatusCode(reason);
const details = reason ? `: ${formatError(reason)}` : "";
const statusLabel = typeof status === "number" ? ` (status ${status})` : "";
return new Error(`WhatsApp QA driver connection closed${statusLabel}${details}`);
}
export async function startWhatsAppQaDriverSession(params: {
authDir: string;
connectionTimeoutMs?: number;
waitForPendingNotifications?: boolean;
}): Promise<WhatsAppQaDriverSession> {
const sock = await createWaSocket(false, false, { authDir: params.authDir });
const observedMessages: WhatsAppQaDriverObservedMessage[] = [];
const pendingNotificationsWaiters: PendingNotificationsWaiter[] = [];
const waiters: Waiter[] = [];
let closed = false;
let closedError: Error | undefined;
let receivedPendingNotifications = false;
const removeWaiter = (waiter: Waiter) => {
const index = waiters.indexOf(waiter);
@@ -84,6 +359,25 @@ export async function startWhatsAppQaDriverSession(params: {
clearTimeout(waiter.timeout);
};
const removePendingNotificationsWaiter = (waiter: PendingNotificationsWaiter) => {
const index = pendingNotificationsWaiters.indexOf(waiter);
if (index >= 0) {
pendingNotificationsWaiters.splice(index, 1);
}
clearTimeout(waiter.timeout);
};
const markPendingNotificationsReceived = () => {
if (receivedPendingNotifications) {
return;
}
receivedPendingNotifications = true;
for (const waiter of pendingNotificationsWaiters.slice()) {
removePendingNotificationsWaiter(waiter);
waiter.resolve();
}
};
const observe = (message: WhatsAppQaDriverObservedMessage) => {
observedMessages.push(message);
for (const waiter of waiters.slice()) {
@@ -104,11 +398,24 @@ export async function startWhatsAppQaDriverSession(params: {
}
};
const onConnectionUpdate = (event: ConnectionUpdateEvent) => {
if (event.receivedPendingNotifications === true) {
markPendingNotificationsReceived();
}
if (event.connection === "close") {
closeSessionResources(createConnectionClosedError(event));
}
};
const removeMessageListener = () => {
const evWithOff = sock.ev as unknown as {
off?: (event: string, listener: (event: MessageUpsertEvent) => void) => void;
off?: (
event: string,
listener: ((event: ConnectionUpdateEvent) => void) | ((event: MessageUpsertEvent) => void),
) => void;
};
evWithOff.off?.("messages.upsert", onMessagesUpsert);
evWithOff.off?.("connection.update", onConnectionUpdate);
};
const closeSessionResources = (waiterError?: Error) => {
@@ -116,6 +423,13 @@ export async function startWhatsAppQaDriverSession(params: {
return;
}
closed = true;
closedError = waiterError;
for (const waiter of pendingNotificationsWaiters.slice()) {
removePendingNotificationsWaiter(waiter);
if (waiterError) {
waiter.reject(waiterError);
}
}
for (const waiter of waiters.slice()) {
removeWaiter(waiter);
if (waiterError) {
@@ -127,8 +441,35 @@ export async function startWhatsAppQaDriverSession(params: {
};
sock.ev.on("messages.upsert", onMessagesUpsert);
sock.ev.on("connection.update", onConnectionUpdate);
try {
await waitForWaConnection(sock, { timeoutMs: params.connectionTimeoutMs ?? 45_000 });
if (params.waitForPendingNotifications) {
await new Promise<void>((resolve, reject) => {
if (receivedPendingNotifications) {
resolve();
return;
}
if (closed) {
reject(closedError ?? new Error("WhatsApp QA driver session closed"));
return;
}
const timeoutMs = params.connectionTimeoutMs ?? 45_000;
const waiter: PendingNotificationsWaiter = {
resolve,
reject,
timeout: setTimeout(() => {
removePendingNotificationsWaiter(waiter);
reject(
new Error(
`timed out after ${timeoutMs}ms waiting for WhatsApp QA driver pending notifications`,
),
);
}, timeoutMs),
};
pendingNotificationsWaiters.push(waiter);
});
}
} catch (error) {
closeSessionResources(
error instanceof Error ? error : new Error("failed starting WhatsApp QA driver session"),
@@ -139,6 +480,7 @@ export async function startWhatsAppQaDriverSession(params: {
const sendApi = createWebSendApi({
sock,
defaultAccountId: "qa-driver",
authDir: params.authDir,
});
return {
@@ -148,20 +490,69 @@ export async function startWhatsAppQaDriverSession(params: {
getObservedMessages() {
return [...observedMessages];
},
async sendText(to, text) {
const result = await sendApi.sendMessage(to, text);
async sendContact(to, contact) {
const result = await sendApi.sendContact(to, contact);
return {
messageId: result.messageId,
};
},
async sendLocation(to, location) {
const result = await sendApi.sendLocation(to, location);
return {
messageId: result.messageId,
};
},
async sendMedia(to, text, mediaBuffer, mediaType, options) {
const result = await sendApi.sendMessage(to, text, mediaBuffer, mediaType, options);
return {
messageId: result.messageId,
};
},
async sendPoll(to, poll) {
const result = await sendApi.sendPoll(to, poll);
return {
messageId: result.messageId,
};
},
async sendReaction(chatJid, messageId, emoji, options) {
const result = await sendApi.sendReaction(
chatJid,
messageId,
emoji,
options.fromMe,
options.participant,
);
return {
messageId: result.messageId,
};
},
async sendSticker(to, stickerBuffer, options) {
const result = await sendApi.sendSticker(to, stickerBuffer, options);
return {
messageId: result.messageId,
};
},
async sendText(to, text, options) {
const result = await sendApi.sendMessage(to, text, undefined, undefined, options);
return {
messageId: result.messageId,
};
},
async waitForMessage(paramsLocal) {
const existing = observedMessages.find(paramsLocal.match);
const predicate = (message: WhatsAppQaDriverObservedMessage) =>
(!paramsLocal.observedAfter ||
new Date(message.observedAt).getTime() >= paramsLocal.observedAfter.getTime()) &&
paramsLocal.match(message);
const existing = observedMessages.find(predicate);
if (existing) {
return existing;
}
if (closed) {
throw closedError ?? new Error("WhatsApp QA driver session closed");
}
return await new Promise<WhatsAppQaDriverObservedMessage>((resolve, reject) => {
const waiter: Waiter = {
predicate: paramsLocal.match,
predicate,
resolve,
reject,
timeout: setTimeout(() => {