Add WhatsApp live QA lane (#77704)

* feat(qa): add whatsapp live lane

* ci: add gated whatsapp and discord qa live lanes

* ci: honor qa live env gates in release selection

* test: update qa live workflow gate assertion

* ci: split live QA release gates
This commit is contained in:
Patrick Erichsen
2026-05-05 12:09:28 -07:00
committed by GitHub
parent 4ddbdff7c0
commit 84e8e09725
24 changed files with 2070 additions and 13 deletions

View File

@@ -154,6 +154,20 @@ gh workflow run "NPM Telegram Beta E2E" --repo openclaw/openclaw --ref main \
gh api repos/openclaw/openclaw/actions/runs/<run-id>/artifacts
```
## WhatsApp live credentials
Use this when setting up or replacing Convex `kind=whatsapp` credentials.
- Treat WhatsApp QA credentials as operator-owned live accounts, not generated fixtures.
- Use two dedicated WhatsApp-capable test numbers: one driver account and one SUT account. Do not use personal numbers or personal OpenClaw WhatsApp accounts in the shared pool.
- Register and link each account manually with WhatsApp or WhatsApp Business, storing Web auth only in isolated local auth dirs outside the repo.
- For group coverage, create a dedicated test group that includes both QA accounts and store its JID as `groupJid`; otherwise the group mention-gating scenario should be skipped by default and fail when explicitly requested.
- Package the two Baileys auth dirs into base64 `.tgz` payload fields and add a new active Convex credential row. Prefer adding a fresh row and disabling stale/broken rows over overwriting credentials in place.
- Expected payload fields: `driverPhoneE164`, `sutPhoneE164`, `driverAuthArchiveBase64`, `sutAuthArchiveBase64`, and optional `groupJid`.
- Keep credential material out of the repo, logs, PRs, and screenshots. Redact phone numbers unless the operator explicitly asks for local debugging.
- Validate with `pnpm openclaw qa whatsapp --credential-source convex --credential-role maintainer --provider-mode mock-openai` and preserve artifact paths plus redacted pass/fail summaries.
- If WhatsApp expires or invalidates a linked Web session, relink locally, package fresh auth archives, add a new Convex row, then disable the stale row.
## Character evals
Use `qa character-eval` for style/persona/vibe checks across multiple live models.

View File

@@ -59,7 +59,7 @@ on:
- qa-parity
- qa-live
live_suite_filter:
description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites
description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram,qa-live-discord,qa-live-whatsapp; blank runs all selected live suites
required: false
default: ""
type: string
@@ -102,6 +102,8 @@ jobs:
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
qa_live_matrix_enabled: ${{ steps.inputs.outputs.qa_live_matrix_enabled }}
qa_live_telegram_enabled: ${{ steps.inputs.outputs.qa_live_telegram_enabled }}
qa_live_discord_enabled: ${{ steps.inputs.outputs.qa_live_discord_enabled }}
qa_live_whatsapp_enabled: ${{ steps.inputs.outputs.qa_live_whatsapp_enabled }}
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
steps:
@@ -222,19 +224,35 @@ jobs:
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
RELEASE_QA_DISCORD_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED || 'false' }}
RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED || 'false' }}
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
run: |
set -euo pipefail
qa_live_matrix_enabled=true
qa_live_telegram_enabled=true
qa_live_slack_enabled=false
qa_live_discord_ci_enabled="$(printf '%s' "$RELEASE_QA_DISCORD_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')"
if [[ "$qa_live_discord_ci_enabled" != "true" && "$qa_live_discord_ci_enabled" != "1" && "$qa_live_discord_ci_enabled" != "yes" ]]; then
qa_live_discord_ci_enabled=false
else
qa_live_discord_ci_enabled=true
fi
qa_live_whatsapp_ci_enabled="$(printf '%s' "$RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')"
if [[ "$qa_live_whatsapp_ci_enabled" != "true" && "$qa_live_whatsapp_ci_enabled" != "1" && "$qa_live_whatsapp_ci_enabled" != "yes" ]]; then
qa_live_whatsapp_ci_enabled=false
else
qa_live_whatsapp_ci_enabled=true
fi
qa_live_slack_ci_enabled="$(printf '%s' "$RELEASE_QA_SLACK_LIVE_CI_ENABLED" | tr '[:upper:]' '[:lower:]')"
if [[ "$qa_live_slack_ci_enabled" != "true" && "$qa_live_slack_ci_enabled" != "1" && "$qa_live_slack_ci_enabled" != "yes" ]]; then
qa_live_slack_ci_enabled=false
else
qa_live_slack_ci_enabled=true
fi
qa_live_discord_enabled="$qa_live_discord_ci_enabled"
qa_live_whatsapp_enabled="$qa_live_whatsapp_ci_enabled"
qa_live_slack_enabled="$qa_live_slack_ci_enabled"
run_release_soak="$(printf '%s' "$RELEASE_RUN_RELEASE_SOAK_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ "$run_release_soak" != "true" && "$run_release_soak" != "1" && "$run_release_soak" != "yes" ]]; then
run_release_soak=false
@@ -250,6 +268,8 @@ jobs:
qa_filter_seen=false
matrix_selected=false
telegram_selected=false
discord_selected=false
whatsapp_selected=false
slack_selected=false
IFS=', ' read -r -a filter_tokens <<< "$filter"
@@ -263,11 +283,16 @@ jobs:
qa_filter_seen=true
matrix_selected=true
telegram_selected=true
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
slack_selected="$qa_live_slack_ci_enabled"
;;
qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack)
qa_filter_seen=true
matrix_selected=true
telegram_selected=true
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
;;
qa-live-matrix|qa-matrix|matrix)
qa_filter_seen=true
@@ -277,6 +302,14 @@ jobs:
qa_filter_seen=true
telegram_selected=true
;;
qa-live-discord|qa-discord|discord)
qa_filter_seen=true
discord_selected="$qa_live_discord_ci_enabled"
;;
qa-live-whatsapp|qa-whatsapp|whatsapp)
qa_filter_seen=true
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
;;
qa-live-slack|qa-slack|slack)
qa_filter_seen=true
slack_selected="$qa_live_slack_ci_enabled"
@@ -287,6 +320,8 @@ jobs:
if [[ "$qa_filter_seen" == "true" ]]; then
qa_live_matrix_enabled="$matrix_selected"
qa_live_telegram_enabled="$telegram_selected"
qa_live_discord_enabled="$discord_selected"
qa_live_whatsapp_enabled="$whatsapp_selected"
qa_live_slack_enabled="$slack_selected"
fi
fi
@@ -302,6 +337,8 @@ jobs:
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
printf 'qa_live_matrix_enabled=%s\n' "$qa_live_matrix_enabled"
printf 'qa_live_telegram_enabled=%s\n' "$qa_live_telegram_enabled"
printf 'qa_live_discord_enabled=%s\n' "$qa_live_discord_enabled"
printf 'qa_live_whatsapp_enabled=%s\n' "$qa_live_whatsapp_enabled"
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
} >> "$GITHUB_OUTPUT"
@@ -337,7 +374,7 @@ jobs:
if [[ -n "${RELEASE_CROSS_OS_SUITE_FILTER// }" ]]; then
echo "- Cross-OS suite filter: \`${RELEASE_CROSS_OS_SUITE_FILTER}\`"
fi
echo "- QA live lanes: Matrix \`${{ steps.inputs.outputs.qa_live_matrix_enabled }}\`, Telegram \`${{ steps.inputs.outputs.qa_live_telegram_enabled }}\`, Slack \`${{ steps.inputs.outputs.qa_live_slack_enabled }}\`"
echo "- QA live lanes: Matrix \`${{ steps.inputs.outputs.qa_live_matrix_enabled }}\`, Telegram \`${{ steps.inputs.outputs.qa_live_telegram_enabled }}\`, Discord \`${{ steps.inputs.outputs.qa_live_discord_enabled }}\`, WhatsApp \`${{ steps.inputs.outputs.qa_live_whatsapp_enabled }}\`, Slack \`${{ steps.inputs.outputs.qa_live_slack_enabled }}\`"
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
else
@@ -926,10 +963,198 @@ jobs:
retention-days: 14
if-no-files-found: warn
qa_live_discord_release_checks:
name: Run QA Lab live Discord lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
environment: qa-live-shared
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run Discord live lane
id: run_lane
shell: bash
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/discord-live-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
for attempt in 1 2; do
attempt_output_dir="${output_dir}/attempt-${attempt}"
if pnpm openclaw qa discord \
--repo-root . \
--output-dir "${attempt_output_dir}" \
--provider-mode mock-openai \
--model mock-openai/gpt-5.5 \
--alt-model mock-openai/gpt-5.5-alt \
--fast \
--credential-source convex \
--credential-role ci; then
exit 0
fi
if [[ "${attempt}" == "2" ]]; then
exit 1
fi
echo "Discord live lane failed on attempt ${attempt}; retrying once..." >&2
sleep 10
done
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_live_whatsapp_release_checks:
name: Run QA Lab live WhatsApp lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
environment: qa-live-shared
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run WhatsApp live lane
id: run_lane
shell: bash
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/whatsapp-live-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
for attempt in 1 2; do
attempt_output_dir="${output_dir}/attempt-${attempt}"
if pnpm openclaw qa whatsapp \
--repo-root . \
--output-dir "${attempt_output_dir}" \
--provider-mode mock-openai \
--model mock-openai/gpt-5.5 \
--alt-model mock-openai/gpt-5.5-alt \
--fast \
--credential-source convex \
--credential-role ci; then
exit 0
fi
if [[ "${attempt}" == "2" ]]; then
exit 1
fi
echo "WhatsApp live lane failed on attempt ${attempt}; retrying once..." >&2
sleep 10
done
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_live_slack_release_checks:
name: Run QA Lab live Slack lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
@@ -1033,6 +1258,8 @@ jobs:
- qa_lab_parity_report_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
- qa_live_discord_release_checks
- qa_live_whatsapp_release_checks
- qa_live_slack_release_checks
if: always()
runs-on: ubuntu-24.04
@@ -1055,6 +1282,8 @@ jobs:
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
"qa_live_whatsapp_release_checks=${{ needs.qa_live_whatsapp_release_checks.result }}" \
"qa_live_slack_release_checks=${{ needs.qa_live_slack_release_checks.result }}"
do
name="${item%%=*}"

View File

@@ -18,6 +18,10 @@ on:
description: Optional comma-separated Discord scenario ids
required: false
type: string
whatsapp_scenario:
description: Optional comma-separated WhatsApp scenario ids
required: false
type: string
slack_scenario:
description: Optional comma-separated Slack scenario ids
required: false
@@ -559,10 +563,102 @@ jobs:
retention-days: 14
if-no-files-found: warn
run_live_whatsapp:
name: Run WhatsApp live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
shell: bash
run: |
set -euo pipefail
require_var() {
local key="$1"
if [[ -z "${!key:-}" ]]; then
echo "Missing required ${key}." >&2
exit 1
fi
}
require_var OPENAI_API_KEY
require_var OPENCLAW_QA_CONVEX_SITE_URL
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
run: pnpm build
- name: Run WhatsApp live lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.whatsapp_scenario || '' }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/whatsapp-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
scenario_args=()
if [[ -n "${INPUT_SCENARIO// }" ]]; then
IFS=',' read -r -a raw_scenarios <<<"${INPUT_SCENARIO}"
for raw in "${raw_scenarios[@]}"; do
scenario="$(printf '%s' "${raw}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -n "${scenario}" ]]; then
scenario_args+=(--scenario "${scenario}")
fi
done
fi
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa whatsapp \
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \
"${scenario_args[@]}"
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_slack:
name: Run Slack live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared

View File

@@ -27,6 +27,21 @@ Docs: https://docs.openclaw.ai
- Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines.
- Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev.
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context.
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.
- Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi.
- Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup.
- Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc.
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
- QA/Mantis: add visual desktop tasks with Crabbox MP4 recording, screenshot capture, and optional image-understanding assertions, and preserve video artifacts in Mantis before/after reports.
- QA/WhatsApp: add `pnpm openclaw qa whatsapp` for live DM canary and pairing-gate coverage using two pre-linked WhatsApp Web sessions from the QA credential pool.
- QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc.
- QA/Mantis: return the copied Slack desktop screenshot path even when remote Slack QA fails, so the CLI still prints the failure screenshot artifact. Thanks @vincentkoc.
- QA/Mantis: accept Blacksmith Testbox `tbx_...` lease ids from desktop smoke warmup, so provider overrides do not fail before inspect/run. Thanks @vincentkoc.
- QA/Codex harness: add targeted live Docker/Testbox diagnostics, auth preflight checks, cache mount fixes, and app-server protocol checkout discovery so maintainer harness failures are easier to reproduce. Thanks @vincentkoc.
- Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev.
- Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar.
- Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install <spec>` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys.

View File

@@ -15,6 +15,7 @@
"@openclaw/discord": "workspace:*",
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/slack": "workspace:*",
"@openclaw/whatsapp": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {

View File

@@ -61,6 +61,7 @@ export type QaGatewayChildStateMutationContext = {
export type QaGatewayChildCommand = {
executablePath: string;
argsPrefix?: string[];
argsSuffix?: string[];
cwd?: string;
usePackagedPlugins?: boolean;
};
@@ -505,6 +506,7 @@ export async function startQaGatewayChild(params: {
const gatewayCommand = params.command;
const gatewayExecutablePath = gatewayCommand?.executablePath;
const gatewayArgsPrefix = gatewayCommand?.argsPrefix ?? [];
const gatewayArgsSuffix = gatewayCommand?.argsSuffix ?? [];
const gatewayCwd = gatewayCommand?.cwd ?? runtimeCwd;
const workspaceDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
@@ -624,6 +626,7 @@ export async function startQaGatewayChild(params: {
"--bind",
"loopback",
"--allow-unconfigured",
...gatewayArgsSuffix,
];
for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) {
gatewayPort = await getFreePort();

View File

@@ -3,6 +3,7 @@ import { discordQaCliRegistration } from "./discord/cli.js";
import type { LiveTransportQaCliRegistration } from "./shared/live-transport-cli.js";
import { slackQaCliRegistration } from "./slack/cli.js";
import { telegramQaCliRegistration } from "./telegram/cli.js";
import { whatsappQaCliRegistration } from "./whatsapp/cli.js";
function createBlockedQaRunnerCliRegistration(params: {
commandName: string;
@@ -40,6 +41,7 @@ const LIVE_TRANSPORT_QA_CLI_REGISTRATIONS: readonly LiveTransportQaCliRegistrati
telegramQaCliRegistration,
discordQaCliRegistration,
slackQaCliRegistration,
whatsappQaCliRegistration,
];
export function listLiveTransportQaCliRegistrations(): readonly LiveTransportQaCliRegistration[] {

View File

@@ -0,0 +1,23 @@
import type { LiveTransportQaCommandOptions } from "../shared/live-transport-cli.js";
import {
printLiveTransportQaArtifacts,
resolveLiveTransportQaRunOptions,
} from "../shared/live-transport-cli.runtime.js";
import { runWhatsAppQaLive } from "./whatsapp-live.runtime.js";
export async function runQaWhatsAppCommand(opts: LiveTransportQaCommandOptions) {
const runOptions = resolveLiveTransportQaRunOptions(opts);
const result = await runWhatsAppQaLive(runOptions);
printLiveTransportQaArtifacts("WhatsApp QA", {
report: result.reportPath,
summary: result.summaryPath,
"observed messages": result.observedMessagesPath,
...(result.gatewayDebugDirPath ? { "gateway debug logs": result.gatewayDebugDirPath } : {}),
});
if (
!runOptions.allowFailures &&
result.scenarios.some((scenario) => scenario.status === "fail")
) {
process.exitCode = 1;
}
}

View File

@@ -0,0 +1,32 @@
import {
createLazyCliRuntimeLoader,
createLiveTransportQaCliRegistration,
type LiveTransportQaCliRegistration,
type LiveTransportQaCommandOptions,
} from "../shared/live-transport-cli.js";
type WhatsAppQaCliRuntime = typeof import("./cli.runtime.js");
const loadWhatsAppQaCliRuntime = createLazyCliRuntimeLoader<WhatsAppQaCliRuntime>(
() => import("./cli.runtime.js"),
);
async function runQaWhatsApp(opts: LiveTransportQaCommandOptions) {
const runtime = await loadWhatsAppQaCliRuntime();
await runtime.runQaWhatsAppCommand(opts);
}
export const whatsappQaCliRegistration: LiveTransportQaCliRegistration =
createLiveTransportQaCliRegistration({
commandName: "whatsapp",
credentialOptions: {
sourceDescription: "Credential source for WhatsApp QA: env or convex (default: env)",
roleDescription:
"Credential role for convex auth: maintainer or ci (default: ci in CI, maintainer otherwise)",
},
description: "Run the WhatsApp live QA lane against two pre-linked Web sessions",
outputDirHelp: "WhatsApp QA artifact directory",
scenarioHelp: "Run only the named WhatsApp QA scenario (repeatable)",
sutAccountHelp: "Temporary WhatsApp account id inside the QA gateway config",
run: runQaWhatsApp,
});

View File

@@ -0,0 +1,33 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
async function listTypeScriptFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return await listTypeScriptFiles(fullPath);
}
return entry.isFile() && entry.name.endsWith(".ts") ? [fullPath] : [];
}),
);
return files.flat();
}
describe("WhatsApp QA transport boundary", () => {
it("uses the exported WhatsApp plugin helper instead of private WhatsApp src imports", async () => {
const files = await listTypeScriptFiles(
path.resolve("extensions/qa-lab/src/live-transports/whatsapp"),
);
const sources = await Promise.all(
files.map(async (file) => [file, await readFile(file, "utf8")] as const),
);
for (const [file, source] of sources) {
expect(source, file).not.toMatch(/extensions\/whatsapp\/src/u);
expect(source, file).not.toMatch(/@openclaw\/whatsapp\/src/u);
}
expect(sources.some(([, source]) => source.includes("@openclaw/whatsapp/api.js"))).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { __testing } from "./whatsapp-live.runtime.js";
const execFileAsync = promisify(execFile);
async function createTgz(params: { entries: Record<string, string>; root: string }) {
const sourceDir = path.join(params.root, "src");
await fs.mkdir(sourceDir, { recursive: true });
for (const [relativePath, content] of Object.entries(params.entries)) {
const filePath = path.join(sourceDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
const archivePath = path.join(params.root, "archive.tgz");
await execFileAsync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
return await fs.readFile(archivePath, "base64");
}
describe("WhatsApp QA live runtime", () => {
it("parses credential payloads and normalizes phone numbers", () => {
expect(
__testing.parseWhatsAppQaCredentialPayload({
driverPhoneE164: "15550000001",
sutPhoneE164: "+15550000002",
driverAuthArchiveBase64: "driver",
sutAuthArchiveBase64: "sut",
}),
).toMatchObject({
driverPhoneE164: "+15550000001",
sutPhoneE164: "+15550000002",
driverAuthArchiveBase64: "driver",
sutAuthArchiveBase64: "sut",
});
});
it("rejects credential payloads that reuse the same phone", () => {
expect(() =>
__testing.parseWhatsAppQaCredentialPayload({
driverPhoneE164: "+15550000001",
sutPhoneE164: "+15550000001",
driverAuthArchiveBase64: "driver",
sutAuthArchiveBase64: "sut",
}),
).toThrow("requires two distinct WhatsApp phone numbers");
});
it("redacts observed message content and phone metadata by default", () => {
expect(
__testing.toObservedWhatsAppArtifacts({
includeContent: false,
redactMetadata: true,
messages: [
{
fromJid: "15550000002@s.whatsapp.net",
fromPhoneE164: "+15550000002",
matchedScenario: true,
messageId: "msg-1",
observedAt: "2026-05-04T12:00:00.000Z",
scenarioId: "whatsapp-canary",
scenarioTitle: "WhatsApp DM canary",
text: "secret body",
},
],
}),
).toEqual([
{
matchedScenario: true,
observedAt: "2026-05-04T12:00:00.000Z",
scenarioId: "whatsapp-canary",
scenarioTitle: "WhatsApp DM canary",
},
]);
});
it("keeps observed message content only when capture is requested", () => {
expect(
__testing.toObservedWhatsAppArtifacts({
includeContent: true,
redactMetadata: true,
messages: [
{
fromPhoneE164: "+15550000002",
observedAt: "2026-05-04T12:00:00.000Z",
text: "captured body",
},
],
}),
).toEqual([
{
observedAt: "2026-05-04T12:00:00.000Z",
text: "captured body",
},
]);
});
it("unpacks auth archives into a caller-provided temp directory", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-qa-test-"));
try {
const archiveBase64 = await createTgz({
root: tempRoot,
entries: {
"creds.json": "{}\n",
"session/key.json": "{}\n",
},
});
const authDir = await __testing.unpackWhatsAppAuthArchive({
archiveBase64,
label: "driver",
parentDir: tempRoot,
});
await expect(fs.readFile(path.join(authDir, "creds.json"), "utf8")).resolves.toBe("{}\n");
await expect(fs.readFile(path.join(authDir, "session/key.json"), "utf8")).resolves.toBe(
"{}\n",
);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("rejects unsafe archive entries before extraction", () => {
expect(() => __testing.assertSafeArchiveEntries(["../creds.json"])).toThrow("unsafe entry");
expect(() => __testing.assertSafeArchiveEntries(["/tmp/creds.json"])).toThrow("unsafe entry");
});
it("registers the WhatsApp canary and pairing scenarios", () => {
expect(__testing.findScenarios(["whatsapp-canary", "whatsapp-pairing-block"])).toMatchObject([
{ id: "whatsapp-canary" },
{ id: "whatsapp-pairing-block" },
]);
});
it("uses automatic visible replies for WhatsApp group mention gating", () => {
const [scenario] = __testing.findScenarios(["whatsapp-mention-gating"]);
const scenarioRun = scenario.buildRun();
expect(scenarioRun.input).toContain("openclawqa reply with only this exact marker");
expect(scenarioRun.input).not.toContain("visible reply tool check");
const cfg = __testing.buildWhatsAppQaConfig(
{},
{
allowFrom: ["+15550000001"],
authDir: "/tmp/openclaw-whatsapp-qa-auth",
dmPolicy: "allowlist",
groupJid: "120363000000000000@g.us",
sutAccountId: "sut",
},
);
expect(cfg.messages?.groupChat?.visibleReplies).toBe("automatic");
expect(cfg.messages?.groupChat?.mentionPatterns).toContain("\\bopenclawqa\\b");
});
it("fails explicitly requested group scenarios when group credentials are missing", () => {
const [scenario] = __testing.findScenarios(["whatsapp-mention-gating"]);
expect(
__testing.createMissingGroupJidScenarioResult({
explicitScenarioSelection: false,
scenario,
}),
).toMatchObject({
id: "whatsapp-mention-gating",
status: "skip",
});
expect(
__testing.createMissingGroupJidScenarioResult({
explicitScenarioSelection: true,
scenario,
}),
).toMatchObject({
id: "whatsapp-mention-gating",
status: "fail",
details: expect.stringContaining("requested scenario requires groupJid"),
});
});
it("attributes pre-scenario setup failures to the selected scenario", () => {
const scenarios = __testing.findScenarios(["whatsapp-mention-gating"]);
const scenarioResults: Array<{
details: string;
id: string;
status: "fail" | "pass" | "skip";
title: string;
}> = [];
__testing.appendPreScenarioFailureResults({
details: "setup exploded",
scenarioResults,
scenarios,
});
expect(scenarioResults).toEqual([
{
id: "whatsapp-mention-gating",
title: "WhatsApp group mention gating",
status: "fail",
details: "setup exploded",
},
]);
});
});

View File

@@ -0,0 +1,930 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { startWhatsAppQaDriverSession } from "@openclaw/whatsapp/api.js";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js";
import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js";
import {
defaultQaModelForMode,
normalizeQaProviderMode,
type QaProviderModeInput,
} from "../../run-config.js";
import {
acquireQaCredentialLease,
startQaCredentialLeaseHeartbeat,
type QaCredentialRole,
} from "../shared/credential-lease.runtime.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { appendLiveLaneIssue, buildLiveLaneArtifactsError } from "../shared/live-lane-helpers.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
type LiveTransportScenarioDefinition,
} from "../shared/live-transport-scenarios.js";
const execFileAsync = promisify(execFile);
export type WhatsAppQaRuntimeEnv = {
driverAuthArchiveBase64: string;
driverPhoneE164: string;
sutAuthArchiveBase64: string;
sutPhoneE164: string;
groupJid?: string;
};
type WhatsAppQaScenarioId =
| "whatsapp-canary"
| "whatsapp-pairing-block"
| "whatsapp-mention-gating";
type WhatsAppQaScenarioRun = {
configMode: "allowlist" | "pairing";
expectReply: boolean;
input: string;
matchText: string | RegExp;
quietInput?: string;
quietMatchText?: string | RegExp;
quietWindowMs?: number;
target: "dm" | "group";
};
type WhatsAppQaScenarioDefinition = LiveTransportScenarioDefinition<WhatsAppQaScenarioId> & {
buildRun: () => WhatsAppQaScenarioRun;
requiresGroupJid?: boolean;
};
type WhatsAppQaDriverObservedMessage = {
fromJid?: string;
fromPhoneE164?: string | null;
messageId?: string;
observedAt: string;
text: string;
};
type WhatsAppQaDriverSession = {
close: () => Promise<void>;
getObservedMessages: () => WhatsAppQaDriverObservedMessage[];
sendText: (to: string, text: string) => Promise<{ messageId?: string }>;
waitForMessage: (params: {
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
timeoutMs: number;
}) => Promise<WhatsAppQaDriverObservedMessage>;
};
type WhatsAppObservedMessage = WhatsAppQaDriverObservedMessage & {
matchedScenario?: boolean;
scenarioId?: string;
scenarioTitle?: string;
};
type WhatsAppObservedMessageArtifact = {
fromPhoneE164?: string | null;
matchedScenario?: boolean;
messageId?: string;
observedAt: string;
scenarioId?: string;
scenarioTitle?: string;
text?: string;
};
type WhatsAppQaScenarioResult = {
details: string;
id: string;
requestStartedAt?: string;
responseObservedAt?: string;
rttMs?: number;
status: "fail" | "pass" | "skip";
title: string;
};
export type WhatsAppQaRunResult = {
gatewayDebugDirPath?: string;
observedMessagesPath: string;
outputDir: string;
reportPath: string;
scenarios: WhatsAppQaScenarioResult[];
summaryPath: string;
};
type WhatsAppQaSummary = {
cleanupIssues: string[];
counts: {
failed: number;
passed: number;
skipped: number;
total: number;
};
credentials: {
credentialId?: string;
kind: string;
ownerId?: string;
role?: QaCredentialRole;
source: "convex" | "env";
};
finishedAt: string;
scenarios: WhatsAppQaScenarioResult[];
startedAt: string;
sutAccountId: string;
sutPhoneE164: string;
};
type WhatsAppCredentialLease = Awaited<
ReturnType<typeof acquireQaCredentialLease<WhatsAppQaRuntimeEnv>>
>;
type WhatsAppCredentialHeartbeat = ReturnType<typeof startQaCredentialLeaseHeartbeat>;
const WHATSAPP_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT";
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
const WHATSAPP_QA_ENV_KEYS = [
"OPENCLAW_QA_WHATSAPP_DRIVER_PHONE_E164",
"OPENCLAW_QA_WHATSAPP_SUT_PHONE_E164",
"OPENCLAW_QA_WHATSAPP_DRIVER_AUTH_ARCHIVE_BASE64",
"OPENCLAW_QA_WHATSAPP_SUT_AUTH_ARCHIVE_BASE64",
] as const;
const whatsappQaCredentialPayloadSchema = z.object({
driverPhoneE164: z.string().trim().min(1),
sutPhoneE164: z.string().trim().min(1),
driverAuthArchiveBase64: z.string().trim().min(1),
sutAuthArchiveBase64: z.string().trim().min(1),
groupJid: z.string().trim().min(1).optional(),
});
const WHATSAPP_QA_SCENARIOS: WhatsAppQaScenarioDefinition[] = [
{
id: "whatsapp-canary",
standardId: "canary",
title: "WhatsApp DM canary",
timeoutMs: 60_000,
buildRun: () => {
const token = `WHATSAPP_QA_ECHO_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `Reply with only this exact marker: ${token}`,
matchText: token,
target: "dm",
};
},
},
{
id: "whatsapp-pairing-block",
standardId: "allowlist-block",
title: "WhatsApp non-allowlisted DM gets pairing gate",
timeoutMs: 20_000,
buildRun: () => ({
configMode: "pairing",
expectReply: true,
input: `Do not run the agent for this pairing QA marker ${randomUUID().slice(0, 8)}`,
matchText: /OpenClaw: access not configured|Pairing code:/iu,
target: "dm",
}),
},
{
id: "whatsapp-mention-gating",
standardId: "mention-gating",
title: "WhatsApp group mention gating",
timeoutMs: 60_000,
requiresGroupJid: true,
buildRun: () => {
const quietToken = `WHATSAPP_QA_GROUP_QUIET_${randomUUID().slice(0, 8).toUpperCase()}`;
const replyToken = `WHATSAPP_QA_GROUP_MENTION_${randomUUID().slice(0, 8).toUpperCase()}`;
return {
configMode: "allowlist",
expectReply: true,
input: `openclawqa reply with only this exact marker: ${replyToken}`,
matchText: replyToken,
quietInput: `This group message is intentionally unmentioned. If you respond, include ${quietToken}.`,
quietMatchText: quietToken,
quietWindowMs: 5_000,
target: "group",
};
},
},
];
export const WHATSAPP_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({
scenarios: WHATSAPP_QA_SCENARIOS,
});
function isTruthyOptIn(value: string | undefined) {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof WHATSAPP_QA_ENV_KEYS)[number]) {
const value = env[key]?.trim();
if (!value) {
throw new Error(`Missing ${key}.`);
}
return value;
}
function inferWhatsAppCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): "convex" | "env" {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}
function inferWhatsAppCredentialRole(value: string | undefined): QaCredentialRole | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "ci" || normalized === "maintainer") {
return normalized;
}
return undefined;
}
function resolveWhatsAppMetadataRedaction(env: NodeJS.ProcessEnv = process.env) {
const raw = env[QA_REDACT_PUBLIC_METADATA_ENV];
return raw === undefined ? true : isTruthyOptIn(raw);
}
function normalizePhone(value: string, label: string) {
const normalized = normalizeE164(value);
if (!/^\+[1-9]\d{6,14}$/u.test(normalized)) {
throw new Error(`${label} must be an E.164 phone number.`);
}
return normalized;
}
function validateWhatsAppQaRuntimeEnv(
runtimeEnv: WhatsAppQaRuntimeEnv,
label: string,
): WhatsAppQaRuntimeEnv {
const driverPhoneE164 = normalizePhone(runtimeEnv.driverPhoneE164, `${label} driverPhoneE164`);
const sutPhoneE164 = normalizePhone(runtimeEnv.sutPhoneE164, `${label} sutPhoneE164`);
if (driverPhoneE164 === sutPhoneE164) {
throw new Error(`${label} requires two distinct WhatsApp phone numbers.`);
}
return {
...runtimeEnv,
driverPhoneE164,
sutPhoneE164,
};
}
function resolveWhatsAppQaRuntimeEnv(env: NodeJS.ProcessEnv = process.env): WhatsAppQaRuntimeEnv {
return validateWhatsAppQaRuntimeEnv(
{
driverPhoneE164: resolveEnvValue(env, "OPENCLAW_QA_WHATSAPP_DRIVER_PHONE_E164"),
sutPhoneE164: resolveEnvValue(env, "OPENCLAW_QA_WHATSAPP_SUT_PHONE_E164"),
driverAuthArchiveBase64: resolveEnvValue(
env,
"OPENCLAW_QA_WHATSAPP_DRIVER_AUTH_ARCHIVE_BASE64",
),
sutAuthArchiveBase64: resolveEnvValue(env, "OPENCLAW_QA_WHATSAPP_SUT_AUTH_ARCHIVE_BASE64"),
groupJid: env.OPENCLAW_QA_WHATSAPP_GROUP_JID?.trim() || undefined,
},
"OPENCLAW_QA_WHATSAPP",
);
}
function parseWhatsAppQaCredentialPayload(payload: unknown): WhatsAppQaRuntimeEnv {
const parsed = whatsappQaCredentialPayloadSchema.parse(payload);
return validateWhatsAppQaRuntimeEnv(parsed, "WhatsApp credential payload");
}
function findScenarios(ids?: string[]) {
return selectLiveTransportScenarios({
ids,
laneLabel: "WhatsApp",
scenarios: WHATSAPP_QA_SCENARIOS,
});
}
function buildWhatsAppQaConfig(
baseCfg: OpenClawConfig,
params: {
allowFrom: string[];
authDir: string;
dmPolicy: "allowlist" | "pairing";
groupJid?: string;
sutAccountId: string;
},
): OpenClawConfig {
const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "whatsapp"])];
return {
...baseCfg,
plugins: {
...baseCfg.plugins,
allow: pluginAllow,
entries: {
...baseCfg.plugins?.entries,
whatsapp: { enabled: true },
},
},
channels: {
...baseCfg.channels,
whatsapp: {
enabled: true,
defaultAccount: params.sutAccountId,
accounts: {
[params.sutAccountId]: {
enabled: true,
authDir: params.authDir,
dmPolicy: params.dmPolicy,
allowFrom: params.allowFrom,
...(params.groupJid
? {
groupPolicy: "open" as const,
groups: {
[params.groupJid]: { requireMention: true },
},
}
: {}),
},
},
},
},
...(params.groupJid
? {
messages: {
...baseCfg.messages,
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic",
mentionPatterns: [
...new Set([
...(baseCfg.messages?.groupChat?.mentionPatterns ?? []),
"\\bopenclawqa\\b",
]),
],
},
},
}
: {}),
};
}
async function waitForWhatsAppChannelRunning(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
accountId: string,
) {
const startedAt = Date.now();
let lastStatus:
| {
connected?: boolean;
lastConnectedAt?: number;
lastDisconnect?: unknown;
lastError?: string;
restartPending?: boolean;
running?: boolean;
}
| undefined;
while (Date.now() - startedAt < 60_000) {
try {
const payload = (await gateway.call(
"channels.status",
{ probe: false, timeoutMs: 2_000 },
{ timeoutMs: 5_000 },
)) as {
channelAccounts?: Record<
string,
Array<{
accountId?: string;
connected?: boolean;
lastConnectedAt?: number;
lastDisconnect?: unknown;
lastError?: string;
restartPending?: boolean;
running?: boolean;
}>
>;
};
const accounts = payload.channelAccounts?.whatsapp ?? [];
const match = accounts.find((entry) => entry.accountId === accountId);
lastStatus = match
? {
connected: match.connected,
lastConnectedAt: match.lastConnectedAt,
lastDisconnect: match.lastDisconnect,
lastError: match.lastError,
restartPending: match.restartPending,
running: match.running,
}
: undefined;
if (match?.running && match.connected === true && match.restartPending !== true) {
return;
}
} catch {
// retry
}
await new Promise((resolve) => setTimeout(resolve, 750));
}
throw new Error(
`whatsapp account "${accountId}" did not become ready` +
(lastStatus ? `; last status: ${JSON.stringify(lastStatus)}` : ""),
);
}
async function listTarEntries(archivePath: string): Promise<string[]> {
const { stdout } = await execFileAsync("tar", ["-tzf", archivePath], {
maxBuffer: 1024 * 1024,
});
return stdout
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean);
}
function assertSafeArchiveEntries(entries: string[]) {
if (entries.length === 0) {
throw new Error("WhatsApp auth archive is empty.");
}
for (const entry of entries) {
if (path.isAbsolute(entry) || entry.split(/[\\/]/u).includes("..")) {
throw new Error(`WhatsApp auth archive contains unsafe entry "${entry}".`);
}
}
}
export async function unpackWhatsAppAuthArchive(params: {
archiveBase64: string;
label: string;
parentDir: string;
}): Promise<string> {
const authDir = path.join(params.parentDir, params.label);
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
const archivePath = path.join(params.parentDir, `${params.label}.tgz`);
await fs.writeFile(archivePath, Buffer.from(params.archiveBase64, "base64"), { mode: 0o600 });
const entries = await listTarEntries(archivePath);
assertSafeArchiveEntries(entries);
await execFileAsync("tar", ["-xzf", archivePath, "-C", authDir], { maxBuffer: 1024 * 1024 });
await fs.rm(archivePath, { force: true });
return authDir;
}
function messageMatches(message: WhatsAppObservedMessage, matchText: string | RegExp) {
return typeof matchText === "string"
? message.text.includes(matchText)
: matchText.test(message.text);
}
async function runWhatsAppScenario(params: {
driver: WhatsAppQaDriverSession;
driverPhoneE164: string;
gatewayDebugDirPath: string;
observedMessages: WhatsAppObservedMessage[];
providerMode: ReturnType<typeof normalizeQaProviderMode>;
primaryModel: string;
alternateModel: string;
fastMode?: boolean;
repoRoot: string;
scenario: WhatsAppQaScenarioDefinition;
sutAccountId: string;
sutAuthDir: string;
sutPhoneE164: string;
groupJid?: string;
}) {
const scenarioRun = params.scenario.buildRun();
if (scenarioRun.target === "group" && !params.groupJid) {
throw new Error(`WhatsApp scenario ${params.scenario.id} requires groupJid.`);
}
const target = scenarioRun.target === "group" ? params.groupJid! : params.sutPhoneE164;
const allowFrom =
scenarioRun.configMode === "allowlist" ? [params.driverPhoneE164] : ["+15550000000"];
const dmPolicy = scenarioRun.configMode === "allowlist" ? "allowlist" : "pairing";
const gatewayHarness = await startQaLiveLaneGateway({
repoRoot: params.repoRoot,
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1:0",
command: {
executablePath: process.execPath,
argsPrefix: [path.join(params.repoRoot, "dist", "index.js")],
argsSuffix: ["--verbose"],
},
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
controlUiEnabled: false,
mutateConfig: (cfg) =>
buildWhatsAppQaConfig(cfg, {
allowFrom,
authDir: params.sutAuthDir,
dmPolicy,
groupJid: scenarioRun.target === "group" ? params.groupJid : undefined,
sutAccountId: params.sutAccountId,
}),
});
let preservedGatewayDebug = false;
try {
await waitForWhatsAppChannelRunning(gatewayHarness.gateway, params.sutAccountId);
if (scenarioRun.quietInput) {
const quietStartedAt = new Date();
await params.driver.sendText(target, scenarioRun.quietInput);
await new Promise((resolve) => setTimeout(resolve, scenarioRun.quietWindowMs ?? 5_000));
const unexpectedReply = params.driver.getObservedMessages().find((message) => {
if (new Date(message.observedAt).getTime() < quietStartedAt.getTime()) {
return false;
}
if (scenarioRun.target === "group" && message.fromJid !== params.groupJid) {
return false;
}
return scenarioRun.quietMatchText
? messageMatches(message as WhatsAppObservedMessage, scenarioRun.quietMatchText)
: true;
});
if (unexpectedReply) {
throw new Error("unexpected WhatsApp group reply before mention gate was triggered");
}
}
const requestStartedAt = new Date();
await params.driver.sendText(target, scenarioRun.input);
if (!scenarioRun.expectReply) {
await new Promise((resolve) => setTimeout(resolve, params.scenario.timeoutMs));
return {
id: params.scenario.id,
title: params.scenario.title,
status: "pass" as const,
details: "no reply",
};
}
const reply = await params.driver.waitForMessage({
timeoutMs: params.scenario.timeoutMs,
match: (message) =>
(scenarioRun.target === "group"
? message.fromJid === params.groupJid
: message.fromPhoneE164 === params.sutPhoneE164) &&
messageMatches(message as WhatsAppObservedMessage, scenarioRun.matchText),
});
const observed: WhatsAppObservedMessage = {
...reply,
matchedScenario: true,
scenarioId: params.scenario.id,
scenarioTitle: params.scenario.title,
};
params.observedMessages.push(observed);
const responseObservedAt = new Date(reply.observedAt);
const rttMs = responseObservedAt.getTime() - requestStartedAt.getTime();
return {
id: params.scenario.id,
title: params.scenario.title,
status: "pass" as const,
details: `reply matched in ${rttMs}ms`,
rttMs,
requestStartedAt: requestStartedAt.toISOString(),
responseObservedAt: responseObservedAt.toISOString(),
};
} catch (error) {
preservedGatewayDebug = true;
await gatewayHarness.gateway
.stop({ preserveToDir: params.gatewayDebugDirPath })
.catch(() => {});
throw error;
} finally {
if (!preservedGatewayDebug) {
await gatewayHarness.stop().catch(() => {});
}
}
}
function toObservedWhatsAppArtifacts(params: {
includeContent: boolean;
messages: WhatsAppObservedMessage[];
redactMetadata: boolean;
}): WhatsAppObservedMessageArtifact[] {
return params.messages.map((message) => ({
fromPhoneE164: params.redactMetadata ? undefined : message.fromPhoneE164,
matchedScenario: message.matchedScenario,
messageId: params.redactMetadata ? undefined : message.messageId,
observedAt: message.observedAt,
scenarioId: message.scenarioId,
scenarioTitle: message.scenarioTitle,
text: params.includeContent ? message.text : undefined,
}));
}
function renderWhatsAppQaMarkdown(params: {
cleanupIssues: string[];
credentialSource: "convex" | "env";
finishedAt: string;
gatewayDebugDirPath?: string;
redactMetadata: boolean;
scenarios: WhatsAppQaScenarioResult[];
startedAt: string;
sutPhoneE164?: string;
}) {
const lines = [
"# WhatsApp QA Report",
"",
`- Credential source: \`${params.credentialSource}\``,
`- SUT phone: \`${params.redactMetadata ? "<redacted>" : (params.sutPhoneE164 ?? "<unavailable>")}\``,
`- Metadata redaction: \`${params.redactMetadata ? "enabled" : "disabled"}\``,
`- Started: ${params.startedAt}`,
`- Finished: ${params.finishedAt}`,
];
if (params.gatewayDebugDirPath) {
lines.push(`- Gateway debug artifacts: \`${params.gatewayDebugDirPath}\``);
}
if (params.cleanupIssues.length > 0) {
lines.push("", "## Cleanup issues", "");
for (const issue of params.cleanupIssues) {
lines.push(`- ${issue}`);
}
}
lines.push("", "## Scenarios", "");
for (const scenario of params.scenarios) {
lines.push(`### ${scenario.title}`, "");
lines.push(`- Status: ${scenario.status}`);
lines.push(`- Details: ${scenario.details}`);
if (scenario.rttMs !== undefined) {
lines.push(`- RTT: ${scenario.rttMs}ms`);
}
lines.push("");
}
return lines.join("\n");
}
function createMissingGroupJidScenarioResult(params: {
explicitScenarioSelection: boolean;
scenario: WhatsAppQaScenarioDefinition;
}): WhatsAppQaScenarioResult {
return {
id: params.scenario.id,
title: params.scenario.title,
status: params.explicitScenarioSelection ? "fail" : "skip",
details: params.explicitScenarioSelection
? "requested scenario requires groupJid in the WhatsApp QA credential payload"
: "requires groupJid in the WhatsApp QA credential payload",
};
}
function appendPreScenarioFailureResults(params: {
details: string;
scenarioResults: WhatsAppQaScenarioResult[];
scenarios: WhatsAppQaScenarioDefinition[];
}) {
const recordedScenarioIds = new Set(params.scenarioResults.map((result) => result.id));
const pendingScenarios = params.scenarios.filter(
(scenario) => !recordedScenarioIds.has(scenario.id),
);
const failedScenarios =
pendingScenarios.length > 0 ? pendingScenarios : params.scenarios.slice(0, 1);
for (const scenario of failedScenarios) {
params.scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "fail",
details: params.details,
});
}
}
export async function runWhatsAppQaLive(params: {
alternateModel?: string;
credentialRole?: string;
credentialSource?: string;
fastMode?: boolean;
outputDir?: string;
primaryModel?: string;
providerMode?: QaProviderModeInput;
repoRoot?: string;
scenarioIds?: string[];
sutAccountId?: string;
}): Promise<WhatsAppQaRunResult> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `whatsapp-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(
params.providerMode ?? DEFAULT_QA_LIVE_PROVIDER_MODE,
);
const primaryModel = params.primaryModel?.trim() || defaultQaModelForMode(providerMode);
const alternateModel = params.alternateModel?.trim() || defaultQaModelForMode(providerMode, true);
const sutAccountId = params.sutAccountId?.trim() || "sut";
const scenarios = findScenarios(params.scenarioIds);
const explicitScenarioSelection = (params.scenarioIds?.length ?? 0) > 0;
const requestedCredentialSource = inferWhatsAppCredentialSource(params.credentialSource);
const requestedCredentialRole = inferWhatsAppCredentialRole(params.credentialRole);
const redactPublicMetadata = resolveWhatsAppMetadataRedaction();
const includeObservedMessageContent = isTruthyOptIn(process.env[WHATSAPP_QA_CAPTURE_CONTENT_ENV]);
const startedAt = new Date().toISOString();
const observedMessages: WhatsAppObservedMessage[] = [];
const scenarioResults: WhatsAppQaScenarioResult[] = [];
const cleanupIssues: string[] = [];
const gatewayDebugDirPath = path.join(outputDir, "gateway-debug");
let preservedGatewayDebugArtifacts = false;
let credentialLease: WhatsAppCredentialLease | undefined;
let leaseHeartbeat: WhatsAppCredentialHeartbeat | undefined;
let runtimeEnv: WhatsAppQaRuntimeEnv | undefined;
let tempAuthRoot: string | undefined;
let driver: WhatsAppQaDriverSession | undefined;
try {
credentialLease = await acquireQaCredentialLease({
kind: "whatsapp",
source: params.credentialSource,
role: params.credentialRole,
resolveEnvPayload: () => resolveWhatsAppQaRuntimeEnv(),
parsePayload: parseWhatsAppQaCredentialPayload,
});
leaseHeartbeat = startQaCredentialLeaseHeartbeat(credentialLease);
const assertLeaseHealthy = () => {
leaseHeartbeat?.throwIfFailed();
};
runtimeEnv = credentialLease.payload;
tempAuthRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-whatsapp-qa-"),
);
const [driverAuthDir, sutAuthDir] = await Promise.all([
unpackWhatsAppAuthArchive({
archiveBase64: runtimeEnv.driverAuthArchiveBase64,
label: "driver-auth",
parentDir: tempAuthRoot,
}),
unpackWhatsAppAuthArchive({
archiveBase64: runtimeEnv.sutAuthArchiveBase64,
label: "sut-auth",
parentDir: tempAuthRoot,
}),
]);
const activeDriver = await startWhatsAppQaDriverSession({ authDir: driverAuthDir });
driver = activeDriver;
for (const scenario of scenarios) {
assertLeaseHealthy();
if (scenario.requiresGroupJid && !runtimeEnv.groupJid) {
scenarioResults.push(
createMissingGroupJidScenarioResult({
explicitScenarioSelection,
scenario,
}),
);
continue;
}
try {
const result = await runWhatsAppScenario({
driver: activeDriver,
driverPhoneE164: runtimeEnv.driverPhoneE164,
gatewayDebugDirPath,
observedMessages,
providerMode,
primaryModel,
alternateModel,
fastMode: params.fastMode,
groupJid: runtimeEnv.groupJid,
repoRoot,
scenario,
sutAccountId,
sutAuthDir,
sutPhoneE164: runtimeEnv.sutPhoneE164,
});
scenarioResults.push(result);
} catch (error) {
preservedGatewayDebugArtifacts = true;
scenarioResults.push({
id: scenario.id,
title: scenario.title,
status: "fail",
details: formatErrorMessage(error),
});
break;
}
}
} catch (error) {
cleanupIssues.push(
buildLiveLaneArtifactsError({
heading: "WhatsApp QA failed before scenario completion.",
details: [formatErrorMessage(error)],
artifacts: {
gatewayDebug: gatewayDebugDirPath,
},
}),
);
preservedGatewayDebugArtifacts = true;
await fs.mkdir(gatewayDebugDirPath, { recursive: true }).catch(() => {});
appendPreScenarioFailureResults({
details: formatErrorMessage(error),
scenarioResults,
scenarios,
});
} finally {
if (driver) {
try {
await driver.close();
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "driver session stop failed", error);
}
}
if (leaseHeartbeat) {
try {
await leaseHeartbeat.stop();
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "credential heartbeat stop failed", error);
}
}
if (credentialLease) {
try {
await credentialLease.release();
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "credential release failed", error);
}
}
if (tempAuthRoot) {
await fs.rm(tempAuthRoot, { recursive: true, force: true }).catch((error) => {
appendLiveLaneIssue(cleanupIssues, "temporary auth cleanup failed", error);
});
}
}
const finishedAt = new Date().toISOString();
const reportPath = path.join(outputDir, "whatsapp-qa-report.md");
const summaryPath = path.join(outputDir, "whatsapp-qa-summary.json");
const observedMessagesPath = path.join(outputDir, "whatsapp-qa-observed-messages.json");
const passed = scenarioResults.filter((entry) => entry.status === "pass").length;
const failed = scenarioResults.filter((entry) => entry.status === "fail").length;
const skipped = scenarioResults.filter((entry) => entry.status === "skip").length;
const summary: WhatsAppQaSummary = {
credentials: credentialLease
? {
source: credentialLease.source,
kind: credentialLease.kind,
role: credentialLease.role,
credentialId: redactPublicMetadata ? undefined : credentialLease.credentialId,
ownerId: redactPublicMetadata ? undefined : credentialLease.ownerId,
}
: {
source: requestedCredentialSource,
kind: "whatsapp",
role: requestedCredentialRole,
},
sutAccountId,
sutPhoneE164: redactPublicMetadata
? "<redacted>"
: (runtimeEnv?.sutPhoneE164 ?? "<unavailable>"),
startedAt,
finishedAt,
cleanupIssues,
counts: {
total: scenarioResults.length,
passed,
failed,
skipped,
},
scenarios: scenarioResults,
};
await fs.writeFile(
observedMessagesPath,
`${JSON.stringify(
toObservedWhatsAppArtifacts({
messages: observedMessages,
includeContent: includeObservedMessageContent,
redactMetadata: redactPublicMetadata,
}),
null,
2,
)}\n`,
);
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
await fs.writeFile(
reportPath,
`${renderWhatsAppQaMarkdown({
cleanupIssues,
credentialSource: credentialLease?.source ?? requestedCredentialSource,
finishedAt,
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
redactMetadata: redactPublicMetadata,
scenarios: scenarioResults,
startedAt,
sutPhoneE164: runtimeEnv?.sutPhoneE164,
})}\n`,
);
return {
outputDir,
reportPath,
summaryPath,
observedMessagesPath,
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
scenarios: scenarioResults,
};
}
export const __testing = {
assertSafeArchiveEntries,
appendPreScenarioFailureResults,
buildWhatsAppQaConfig,
createMissingGroupJidScenarioResult,
findScenarios,
parseWhatsAppQaCredentialPayload,
resolveWhatsAppQaRuntimeEnv,
resolveWhatsAppMetadataRedaction,
toObservedWhatsAppArtifacts,
unpackWhatsAppAuthArchive,
WHATSAPP_QA_STANDARD_SCENARIO_IDS,
};

View File

@@ -38,6 +38,7 @@
"@openclaw/qa-channel/api.js": ["../dist/plugin-sdk/extensions/qa-channel/api.d.ts"],
"@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"],
"@openclaw/slack/api.js": ["../dist/plugin-sdk/extensions/slack/api.d.ts"],
"@openclaw/whatsapp/api.js": ["../dist/plugin-sdk/extensions/whatsapp/api.d.ts"],
"@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"],
"@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"],
"@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/src/plugin-sdk/*.d.ts"]

View File

@@ -211,3 +211,8 @@ export {
} from "./src/normalize-target.js";
export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js";
export { __testing as whatsappAccessControlTesting } from "./src/inbound/access-control.js";
export {
startWhatsAppQaDriverSession,
type WhatsAppQaDriverObservedMessage,
type WhatsAppQaDriverSession,
} from "./src/qa-driver.runtime.js";

View File

@@ -0,0 +1,123 @@
import { EventEmitter } from "node:events";
import type { WAMessage } from "@whiskeysockets/baileys";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startWhatsAppQaDriverSession } from "./qa-driver.runtime.js";
const mocks = vi.hoisted(() => ({
createWaSocket: vi.fn(),
jidToE164: vi.fn(),
sendMessage: vi.fn(),
waitForWaConnection: vi.fn(),
}));
vi.mock("./session.js", () => ({
createWaSocket: mocks.createWaSocket,
waitForWaConnection: mocks.waitForWaConnection,
}));
vi.mock("./text-runtime.js", () => ({
jidToE164: mocks.jidToE164,
}));
vi.mock("./inbound/send-api.js", () => ({
createWebSendApi: () => ({
sendMessage: mocks.sendMessage,
}),
}));
function createMockSocket() {
return {
end: vi.fn(),
ev: new EventEmitter(),
ws: {
close: vi.fn(),
},
};
}
function incomingMessage(remoteJid: string, text: string): WAMessage {
return {
key: {
fromMe: false,
id: "message-1",
remoteJid,
},
message: {
conversation: text,
},
} as WAMessage;
}
describe("startWhatsAppQaDriverSession", () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("normalizes LID-backed senders using the QA auth directory", 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: [incomingMessage("12345@lid", "hello")],
});
expect(mocks.jidToE164).toHaveBeenCalledWith("12345@lid", {
authDir: "/tmp/openclaw-whatsapp-auth",
});
expect(session.getObservedMessages()).toMatchObject([
{
fromJid: "12345@lid",
fromPhoneE164: "+15551234567",
messageId: "message-1",
text: "hello",
},
]);
await session.close();
});
it("clears the connection timeout after a successful connection", async () => {
vi.useFakeTimers();
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockResolvedValue(undefined);
const session = await startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
connectionTimeoutMs: 45_000,
});
expect(vi.getTimerCount()).toBe(0);
await session.close();
});
it("closes the socket and removes listeners when connection setup times out", async () => {
vi.useFakeTimers();
const sock = createMockSocket();
mocks.createWaSocket.mockResolvedValue(sock);
mocks.waitForWaConnection.mockReturnValue(new Promise(() => {}));
const started = startWhatsAppQaDriverSession({
authDir: "/tmp/openclaw-whatsapp-auth",
connectionTimeoutMs: 10,
});
const rejection = started.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10);
const error = await rejection;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("timed out waiting for WhatsApp QA driver session");
expect(sock.ev.listenerCount("messages.upsert")).toBe(0);
expect(sock.end).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
});

View File

@@ -0,0 +1,189 @@
import type { WAMessage } from "@whiskeysockets/baileys";
import { extractText } from "./inbound/extract.js";
import { createWebSendApi } from "./inbound/send-api.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
import { jidToE164 } from "./text-runtime.js";
export type WhatsAppQaDriverObservedMessage = {
fromJid?: string;
fromPhoneE164?: string | null;
messageId?: string;
observedAt: string;
text: string;
};
export type WhatsAppQaDriverSession = {
close: () => Promise<void>;
getObservedMessages: () => WhatsAppQaDriverObservedMessage[];
sendText: (to: string, text: string) => Promise<{ messageId?: string }>;
waitForMessage: (params: {
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
timeoutMs: number;
}) => Promise<WhatsAppQaDriverObservedMessage>;
};
type MessageUpsertEvent = {
messages?: WAMessage[];
};
type Waiter = {
predicate: (message: WhatsAppQaDriverObservedMessage) => boolean;
reject: (error: Error) => void;
resolve: (message: WhatsAppQaDriverObservedMessage) => void;
timeout: NodeJS.Timeout;
};
function normalizeObservedMessage(
message: WAMessage,
authDir: string,
): WhatsAppQaDriverObservedMessage | null {
if (message.key.fromMe) {
return null;
}
const text = extractText(message.message ?? undefined);
if (!text) {
return null;
}
const fromJid = message.key.remoteJid ?? undefined;
return {
fromJid,
fromPhoneE164: fromJid ? jidToE164(fromJid, { authDir }) : null,
messageId: message.key.id ?? undefined,
observedAt: new Date().toISOString(),
text,
};
}
function closeSocket(sock: Awaited<ReturnType<typeof createWaSocket>>) {
const maybeEnd = (sock as unknown as { end?: (error?: Error) => void }).end;
if (typeof maybeEnd === "function") {
maybeEnd.call(sock);
return;
}
const maybeClose = (sock.ws as unknown as { close?: () => void } | undefined)?.close;
if (typeof maybeClose === "function") {
maybeClose.call(sock.ws);
}
}
export async function startWhatsAppQaDriverSession(params: {
authDir: string;
connectionTimeoutMs?: number;
}): Promise<WhatsAppQaDriverSession> {
const sock = await createWaSocket(false, false, { authDir: params.authDir });
const observedMessages: WhatsAppQaDriverObservedMessage[] = [];
const waiters: Waiter[] = [];
let closed = false;
const removeWaiter = (waiter: Waiter) => {
const index = waiters.indexOf(waiter);
if (index >= 0) {
waiters.splice(index, 1);
}
clearTimeout(waiter.timeout);
};
const observe = (message: WhatsAppQaDriverObservedMessage) => {
observedMessages.push(message);
for (const waiter of waiters.slice()) {
if (!waiter.predicate(message)) {
continue;
}
removeWaiter(waiter);
waiter.resolve(message);
}
};
const onMessagesUpsert = (event: MessageUpsertEvent) => {
for (const rawMessage of event.messages ?? []) {
const observed = normalizeObservedMessage(rawMessage, params.authDir);
if (observed) {
observe(observed);
}
}
};
const removeMessageListener = () => {
const evWithOff = sock.ev as unknown as {
off?: (event: string, listener: (event: MessageUpsertEvent) => void) => void;
};
evWithOff.off?.("messages.upsert", onMessagesUpsert);
};
const closeSessionResources = (waiterError?: Error) => {
if (closed) {
return;
}
closed = true;
for (const waiter of waiters.slice()) {
removeWaiter(waiter);
if (waiterError) {
waiter.reject(waiterError);
}
}
removeMessageListener();
closeSocket(sock);
};
sock.ev.on("messages.upsert", onMessagesUpsert);
let connectionTimeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
waitForWaConnection(sock),
new Promise<never>((_, reject) => {
connectionTimeout = setTimeout(
() => reject(new Error("timed out waiting for WhatsApp QA driver session")),
params.connectionTimeoutMs ?? 45_000,
);
connectionTimeout.unref?.();
}),
]);
} catch (error) {
closeSessionResources(
error instanceof Error ? error : new Error("failed starting WhatsApp QA driver session"),
);
throw error;
} finally {
if (connectionTimeout) {
clearTimeout(connectionTimeout);
}
}
const sendApi = createWebSendApi({
sock,
defaultAccountId: "qa-driver",
});
return {
async close() {
closeSessionResources(new Error("WhatsApp QA driver session closed"));
},
getObservedMessages() {
return [...observedMessages];
},
async sendText(to, text) {
const result = await sendApi.sendMessage(to, text);
return {
messageId: result.messageId,
};
},
async waitForMessage(params) {
const existing = observedMessages.find(params.match);
if (existing) {
return existing;
}
return await new Promise<WhatsAppQaDriverObservedMessage>((resolve, reject) => {
const waiter: Waiter = {
predicate: params.match,
resolve,
reject,
timeout: setTimeout(() => {
removeWaiter(waiter);
reject(new Error("timed out waiting for WhatsApp QA driver message"));
}, params.timeoutMs),
};
waiters.push(waiter);
});
},
};
}

3
pnpm-lock.yaml generated
View File

@@ -1204,6 +1204,9 @@ importers:
'@openclaw/slack':
specifier: workspace:*
version: link:../slack
'@openclaw/whatsapp':
specifier: workspace:*
version: link:../whatsapp
openclaw:
specifier: workspace:*
version: link:../..

View File

@@ -15,4 +15,9 @@ Key workflow:
- `qa manual` is the scoped personality and style probe after the executable subset is green.
- `qa coverage` prints the scenario coverage inventory from scenario frontmatter.
Operator workflows:
- Use the `openclaw-qa-testing` skill for QA Lab live lanes, Convex credential
pool operations, and WhatsApp live credential setup/replacement.
Keep this folder in git. Add new scenarios here before wiring them into automation.

View File

@@ -13,6 +13,7 @@ export class CredentialPayloadValidationError extends Error {
type PayloadValidationFailureFactory = (httpStatus: number, code: string, message: string) => Error;
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
const E164_RE = /^\+[1-9]\d{6,14}$/u;
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) {
@@ -106,6 +107,65 @@ function normalizeDiscordCredentialPayload(
} satisfies Record<string, unknown>;
}
function requireE164PayloadString(
payload: Record<string, unknown>,
key: string,
kind: string,
createFailure: PayloadValidationFailureFactory,
) {
const value = requirePayloadString(payload, key, kind, createFailure);
if (!E164_RE.test(value)) {
throwPayloadError(
createFailure,
`Credential payload for kind "${kind}" must include "${key}" as an E.164 phone number string.`,
);
}
return value;
}
function normalizeWhatsAppCredentialPayload(
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory,
) {
const driverPhoneE164 = requireE164PayloadString(
payload,
"driverPhoneE164",
"whatsapp",
createFailure,
);
const sutPhoneE164 = requireE164PayloadString(payload, "sutPhoneE164", "whatsapp", createFailure);
if (driverPhoneE164 === sutPhoneE164) {
throwPayloadError(
createFailure,
'Credential payload for kind "whatsapp" must use distinct driverPhoneE164 and sutPhoneE164 values.',
);
}
const driverAuthArchiveBase64 = requirePayloadString(
payload,
"driverAuthArchiveBase64",
"whatsapp",
createFailure,
);
const sutAuthArchiveBase64 = requirePayloadString(
payload,
"sutAuthArchiveBase64",
"whatsapp",
createFailure,
);
const groupJid =
typeof payload.groupJid === "string" && payload.groupJid.trim()
? payload.groupJid.trim()
: undefined;
return {
driverPhoneE164,
sutPhoneE164,
driverAuthArchiveBase64,
sutAuthArchiveBase64,
...(groupJid ? { groupJid } : {}),
} satisfies Record<string, unknown>;
}
export function normalizeCredentialPayloadForKind(
kind: string,
payload: Record<string, unknown>,
@@ -117,5 +177,8 @@ export function normalizeCredentialPayloadForKind(
if (kind === "discord") {
return normalizeDiscordCredentialPayload(payload, createFailure);
}
if (kind === "whatsapp") {
return normalizeWhatsAppCredentialPayload(payload, createFailure);
}
return payload;
}

View File

@@ -48,6 +48,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"@openclaw/qa-channel/api.js": ["../dist/plugin-sdk/extensions/qa-channel/api.d.ts"],
"@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"],
"@openclaw/slack/api.js": ["../dist/plugin-sdk/extensions/slack/api.d.ts"],
"@openclaw/whatsapp/api.js": ["../dist/plugin-sdk/extensions/whatsapp/api.d.ts"],
"@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"],
"@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"],
"@openclaw/plugin-sdk/*": ["../dist/plugin-sdk/src/plugin-sdk/*.d.ts"],
@@ -72,6 +73,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_XAI_PATHS = {
"openclaw/plugin-sdk/channel-secret-tts-runtime": _omitTts,
"@openclaw/discord/api.js": _omitDiscord,
"@openclaw/slack/api.js": _omitSlack,
"@openclaw/whatsapp/api.js": _omitWhatsApp,
...rest
}) => rest)(EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS),
"../",

View File

@@ -59,6 +59,13 @@ const SLACK_DTS_INPUTS = [
];
const SLACK_DTS_STAMP = "dist/plugin-sdk/extensions/slack/.boundary-dts.stamp";
const SLACK_DTS_REQUIRED_OUTPUTS = ["dist/plugin-sdk/extensions/slack/api.d.ts"];
const WHATSAPP_DTS_INPUTS = [
"extensions/whatsapp/api.ts",
"extensions/whatsapp/src/qa-driver.runtime.ts",
"extensions/whatsapp/tsconfig.json",
];
const WHATSAPP_DTS_STAMP = "dist/plugin-sdk/extensions/whatsapp/.boundary-dts.stamp";
const WHATSAPP_DTS_REQUIRED_OUTPUTS = ["dist/plugin-sdk/extensions/whatsapp/api.d.ts"];
const ENTRY_SHIMS_INPUTS = [
"scripts/write-plugin-sdk-entry-dts.ts",
"scripts/lib/plugin-sdk-entrypoints.json",
@@ -316,6 +323,12 @@ async function main(argv = process.argv.slice(2)) {
outputPaths: [SLACK_DTS_STAMP, ...SLACK_DTS_REQUIRED_OUTPUTS],
includeFile: isRelevantTypeInput,
}) && !hasMissingOutput(SLACK_DTS_REQUIRED_OUTPUTS);
const whatsappDtsFresh =
isArtifactSetFresh({
inputPaths: WHATSAPP_DTS_INPUTS,
outputPaths: [WHATSAPP_DTS_STAMP, ...WHATSAPP_DTS_REQUIRED_OUTPUTS],
includeFile: isRelevantTypeInput,
}) && !hasMissingOutput(WHATSAPP_DTS_REQUIRED_OUTPUTS);
const prerequisiteSteps = [];
const dependentSteps = [];
@@ -445,6 +458,37 @@ async function main(argv = process.argv.slice(2)) {
} else {
process.stdout.write("[slack boundary dts] fresh; skipping\n");
}
if (!whatsappDtsFresh) {
removeIncrementalStateForMissingOutput({
outputPaths: WHATSAPP_DTS_REQUIRED_OUTPUTS,
tsBuildInfoPath: "dist/plugin-sdk/extensions/whatsapp/.tsbuildinfo",
});
dependentSteps.push({
label: "whatsapp boundary dts",
args: [
runTsgoScript,
"-p",
"extensions/whatsapp/tsconfig.json",
"--declaration",
"true",
"--emitDeclarationOnly",
"true",
"--noEmit",
"false",
"--outDir",
"dist/plugin-sdk/extensions/whatsapp",
"--rootDir",
"extensions/whatsapp",
"--tsBuildInfoFile",
"dist/plugin-sdk/extensions/whatsapp/.tsbuildinfo",
],
env: { OPENCLAW_TSGO_HEAVY_CHECK_LOCK_HELD: "1" },
timeoutMs: 300_000,
stampPath: WHATSAPP_DTS_STAMP,
});
} else {
process.stdout.write("[whatsapp boundary dts] fresh; skipping\n");
}
}
if (prerequisiteSteps.length > 0) {

View File

@@ -53,4 +53,33 @@ describe("QA Convex credential payload validation", () => {
expect(normalizeCredentialPayloadForKind("future-kind", payload)).toBe(payload);
});
it("normalizes WhatsApp credential payloads", () => {
expect(
normalizeCredentialPayloadForKind("whatsapp", {
driverPhoneE164: "+15550000001",
sutPhoneE164: "+15550000002",
driverAuthArchiveBase64: "driver-archive",
sutAuthArchiveBase64: "sut-archive",
groupJid: "120363000000000000@g.us",
}),
).toEqual({
driverPhoneE164: "+15550000001",
sutPhoneE164: "+15550000002",
driverAuthArchiveBase64: "driver-archive",
sutAuthArchiveBase64: "sut-archive",
groupJid: "120363000000000000@g.us",
});
});
it("rejects WhatsApp payloads with duplicate phone numbers", () => {
expect(() =>
normalizeCredentialPayloadForKind("whatsapp", {
driverPhoneE164: "+15550000001",
sutPhoneE164: "+15550000001",
driverAuthArchiveBase64: "driver-archive",
sutAuthArchiveBase64: "sut-archive",
}),
).toThrow("distinct driverPhoneE164 and sutPhoneE164");
});
});

View File

@@ -610,16 +610,21 @@ describe("package artifact reuse", () => {
);
});
it("keeps Slack live QA disabled in CI until credentials are provisioned", () => {
it("runs live transport lanes nightly while release checks stay gated", () => {
const releaseWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8");
const qaWorkflow = readFileSync(QA_LIVE_TRANSPORTS_WORKFLOW, "utf8");
expect(releaseWorkflow).toContain("qa_live_slack_enabled=false");
expect(releaseWorkflow).toContain(
"RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED || 'false' }}",
);
expect(releaseWorkflow).toContain("vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'");
expect(qaWorkflow).toContain("if: vars.OPENCLAW_QA_SLACK_LIVE_CI_ENABLED == 'true'");
for (const channel of ["DISCORD", "WHATSAPP", "SLACK"]) {
const lower = channel.toLowerCase();
expect(releaseWorkflow).toContain(
`RELEASE_QA_${channel}_LIVE_CI_ENABLED: \${{ vars.OPENCLAW_RELEASE_QA_${channel}_LIVE_CI_ENABLED || 'false' }}`,
);
expect(releaseWorkflow).toContain(`qa_live_${lower}_enabled="$qa_live_${lower}_ci_enabled"`);
expect(releaseWorkflow).toContain(
`vars.OPENCLAW_RELEASE_QA_${channel}_LIVE_CI_ENABLED == 'true'`,
);
expect(qaWorkflow).not.toContain(`OPENCLAW_QA_${channel}_LIVE_CI_ENABLED`);
}
});
it("names package acceptance Telegram as artifact-backed package validation", () => {

View File

@@ -147,6 +147,10 @@ export const sharedVitestConfig = {
find: "@openclaw/slack/api.js",
replacement: path.join(repoRoot, "extensions", "slack", "api.ts"),
},
{
find: "@openclaw/whatsapp/api.js",
replacement: path.join(repoRoot, "extensions", "whatsapp", "api.ts"),
},
...sourcePluginSdkSubpaths.map((subpath) => ({
find: `openclaw/plugin-sdk/${subpath}`,
replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),