diff --git a/.agents/skills/openclaw-qa-testing/SKILL.md b/.agents/skills/openclaw-qa-testing/SKILL.md index 8cbed5ff010..16f616dc468 100644 --- a/.agents/skills/openclaw-qa-testing/SKILL.md +++ b/.agents/skills/openclaw-qa-testing/SKILL.md @@ -154,6 +154,20 @@ gh workflow run "NPM Telegram Beta E2E" --repo openclaw/openclaw --ref main \ gh api repos/openclaw/openclaw/actions/runs//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. diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 7612423e86d..23edfad3cfa 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -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%%=*}" diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index e385423ea99..bc475a34c0a 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ca40c4958..6b993d84231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys. diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index 0e9a1133e36..7c759720406 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -15,6 +15,7 @@ "@openclaw/discord": "workspace:*", "@openclaw/plugin-sdk": "workspace:*", "@openclaw/slack": "workspace:*", + "@openclaw/whatsapp": "workspace:*", "openclaw": "workspace:*" }, "peerDependencies": { diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index ba706677cb1..40fab0609fd 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -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(); diff --git a/extensions/qa-lab/src/live-transports/cli.ts b/extensions/qa-lab/src/live-transports/cli.ts index 92a0fbcf893..b36b4423fae 100644 --- a/extensions/qa-lab/src/live-transports/cli.ts +++ b/extensions/qa-lab/src/live-transports/cli.ts @@ -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[] { diff --git a/extensions/qa-lab/src/live-transports/whatsapp/cli.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/cli.runtime.ts new file mode 100644 index 00000000000..d2a3f647b1d --- /dev/null +++ b/extensions/qa-lab/src/live-transports/whatsapp/cli.runtime.ts @@ -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; + } +} diff --git a/extensions/qa-lab/src/live-transports/whatsapp/cli.ts b/extensions/qa-lab/src/live-transports/whatsapp/cli.ts new file mode 100644 index 00000000000..6d1a5db8866 --- /dev/null +++ b/extensions/qa-lab/src/live-transports/whatsapp/cli.ts @@ -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( + () => 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, + }); diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts new file mode 100644 index 00000000000..fa2eb05d3b3 --- /dev/null +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-boundary.test.ts @@ -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 { + 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); + }); +}); diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts new file mode 100644 index 00000000000..b66c9081450 --- /dev/null +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts @@ -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; 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", + }, + ]); + }); +}); diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts new file mode 100644 index 00000000000..c2f8aa4d3f9 --- /dev/null +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts @@ -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 & { + buildRun: () => WhatsAppQaScenarioRun; + requiresGroupJid?: boolean; +}; + +type WhatsAppQaDriverObservedMessage = { + fromJid?: string; + fromPhoneE164?: string | null; + messageId?: string; + observedAt: string; + text: string; +}; + +type WhatsAppQaDriverSession = { + close: () => Promise; + getObservedMessages: () => WhatsAppQaDriverObservedMessage[]; + sendText: (to: string, text: string) => Promise<{ messageId?: string }>; + waitForMessage: (params: { + match: (message: WhatsAppQaDriverObservedMessage) => boolean; + timeoutMs: number; + }) => Promise; +}; + +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> +>; +type WhatsAppCredentialHeartbeat = ReturnType; + +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>, + 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 { + 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 { + 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; + 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 ? "" : (params.sutPhoneE164 ?? "")}\``, + `- 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 { + 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 + ? "" + : (runtimeEnv?.sutPhoneE164 ?? ""), + 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, +}; diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index e27f1b6b781..100890932a8 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -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"] diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index 70839d9f409..8bc33dd1d22 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -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"; diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts new file mode 100644 index 00000000000..da61440fb70 --- /dev/null +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -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); + }); +}); diff --git a/extensions/whatsapp/src/qa-driver.runtime.ts b/extensions/whatsapp/src/qa-driver.runtime.ts new file mode 100644 index 00000000000..07dd9f1a23d --- /dev/null +++ b/extensions/whatsapp/src/qa-driver.runtime.ts @@ -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; + getObservedMessages: () => WhatsAppQaDriverObservedMessage[]; + sendText: (to: string, text: string) => Promise<{ messageId?: string }>; + waitForMessage: (params: { + match: (message: WhatsAppQaDriverObservedMessage) => boolean; + timeoutMs: number; + }) => Promise; +}; + +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>) { + 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 { + 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((_, 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((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); + }); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c13fb6d9251..8c5587f9114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1204,6 +1204,9 @@ importers: '@openclaw/slack': specifier: workspace:* version: link:../slack + '@openclaw/whatsapp': + specifier: workspace:* + version: link:../whatsapp openclaw: specifier: workspace:* version: link:../.. diff --git a/qa/README.md b/qa/README.md index cc07d65936d..1f9380604fc 100644 --- a/qa/README.md +++ b/qa/README.md @@ -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. diff --git a/qa/convex-credential-broker/convex/payload-validation.ts b/qa/convex-credential-broker/convex/payload-validation.ts index 901e81e049d..778fef019a9 100644 --- a/qa/convex-credential-broker/convex/payload-validation.ts +++ b/qa/convex-credential-broker/convex/payload-validation.ts @@ -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; } +function requireE164PayloadString( + payload: Record, + 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, + 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; +} + export function normalizeCredentialPayloadForKind( kind: string, payload: Record, @@ -117,5 +177,8 @@ export function normalizeCredentialPayloadForKind( if (kind === "discord") { return normalizeDiscordCredentialPayload(payload, createFailure); } + if (kind === "whatsapp") { + return normalizeWhatsAppCredentialPayload(payload, createFailure); + } return payload; } diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index 815404fe55b..69a4c569abb 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -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), "../", diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index b36aaa11ddd..0a3259bdbe0 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -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) { diff --git a/test/qa-convex-credential-payload-validation.test.ts b/test/qa-convex-credential-payload-validation.test.ts index 6b0ea895c64..e046dd0071f 100644 --- a/test/qa-convex-credential-payload-validation.test.ts +++ b/test/qa-convex-credential-payload-validation.test.ts @@ -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"); + }); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 95559c30241..67e8c77174f 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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", () => { diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 7256b692c16..635ebfebaee 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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`),