mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
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:
@@ -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.
|
||||
|
||||
239
.github/workflows/openclaw-release-checks.yml
vendored
239
.github/workflows/openclaw-release-checks.yml
vendored
@@ -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%%=*}"
|
||||
|
||||
98
.github/workflows/qa-live-transports-convex.yml
vendored
98
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -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
|
||||
|
||||
15
CHANGELOG.md
15
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 <spec>` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@openclaw/discord": "workspace:*",
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@openclaw/slack": "workspace:*",
|
||||
"@openclaw/whatsapp": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
32
extensions/qa-lab/src/live-transports/whatsapp/cli.ts
Normal file
32
extensions/qa-lab/src/live-transports/whatsapp/cli.ts
Normal 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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"]
|
||||
|
||||
@@ -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";
|
||||
|
||||
123
extensions/whatsapp/src/qa-driver.runtime.test.ts
Normal file
123
extensions/whatsapp/src/qa-driver.runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
189
extensions/whatsapp/src/qa-driver.runtime.ts
Normal file
189
extensions/whatsapp/src/qa-driver.runtime.ts
Normal 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
3
pnpm-lock.yaml
generated
@@ -1204,6 +1204,9 @@ importers:
|
||||
'@openclaw/slack':
|
||||
specifier: workspace:*
|
||||
version: link:../slack
|
||||
'@openclaw/whatsapp':
|
||||
specifier: workspace:*
|
||||
version: link:../whatsapp
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
"../",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user