mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user