Compare commits

..

1 Commits

Author SHA1 Message Date
Mason Huang
35dedb8e40 fix(telegram): avoid rich messages in group chats 2026-06-16 06:58:16 +08:00
226 changed files with 3117 additions and 12687 deletions

View File

@@ -24,7 +24,7 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
- Keep going until structured review returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
@@ -43,42 +43,6 @@ Use when:
- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub.
- Do not push just to review. Push only when the user requested push/ship/PR update.
## Scope Governor
Autoreview is a closeout gate, not permission to rewrite the task.
Before the first review, freeze a scope baseline: original request or issue, target branch, intended behavior, owner boundary, changed files, and non-test LOC. For inherited or already-bloated branches, use the intended PR diff as the baseline rather than accepting all existing branch drift.
Before patching a finding, classify it:
- **In-scope blocker**: the finding is introduced by the current diff, affects the same owner boundary, and can be fixed without changing the task's contract.
- **Follow-up**: the finding is real but belongs to an adjacent bug class, sibling surface, cleanup, or broader hardening track.
- **Stop-and-escalate**: the finding requires a new protocol/config/storage/public API contract, a different owner boundary, a release-process change, or a design choice outside the original request.
Stop patching and report the scope break instead of continuing when:
- a narrow PR turns into an architecture change, protocol change, migration, or release-process change;
- the diff grows past 2x the original files or non-test LOC without explicit approval to expand scope;
- two review-triggered patch cycles have not converged; pause and reclassify every remaining finding before another edit;
- the best fix is "define the canonical contract first" rather than another local inference layer;
- fixing the accepted finding would make the PR no longer describe the same behavior, issue, or owner boundary.
After the two-cycle pause, continue only when every remaining accepted finding is still an in-scope blocker. Otherwise preserve the useful analysis, identify the smallest safe landed subset if one exists, and open or request a follow-up for the larger fix. Do not keep committing speculative fixes just to satisfy the reviewer.
Do not stack or push review-triggered fix commits while scope classification or focused proof is unresolved. Keep exploratory edits local until the cycle is proven in scope; if scope breaks, remove them from the landing lane instead of preserving them as branch history.
Critical exceptions must be explicit: active data loss, crash, broken install/upgrade, release blocker, or concrete security exposure. If the exception is not one of those, it is not critical enough to blow up scope.
## Release Branches And Release Process
On release, beta, stable, hotfix, signing, notarization, appcast, package-publish, or release-check work, use freeze discipline even when the branch name is not release-like:
- Fix only release blockers, failed release infrastructure, exact backports, install/upgrade breakage, data loss, crashes, or concrete security exposure.
- Treat non-blocking autoreview findings as follow-ups for `main`, not reasons to broaden the release branch.
- Do not introduce new product behavior, config surface, protocol shape, migration, plugin ownership, docs narrative, or process policy unless it directly unblocks the release.
- Keep proof tied to the release target: exact branch/ref, failing check or shipped-risk reason, smallest command/proof, and whether the fix must also forward-port to `main`.
- If review discovers a real but non-critical design problem during release closeout, stop with a follow-up issue/PR plan; do not use the release branch as the refactor lane.
## Pick Target
Dirty local work:

View File

@@ -440,36 +440,8 @@ def load_datasets(args: argparse.Namespace) -> str:
return "\n\n".join(chunks)
def review_scope_policy() -> str:
return textwrap.dedent(
"""
Review scope discipline:
- This helper is a closeout gate. Do not turn a narrow patch into a broad
redesign request.
- Report a finding only when this diff introduces or exposes a concrete
defect that must be fixed before this target can land.
- If the best fix requires a new protocol, config, storage, public API,
release process, migration, owner-boundary move, or canonical contract,
say that directly in the finding and keep the finding tied to the
smallest changed line that proves the current patch is not landable.
- Do not ask for sibling-surface hardening, cleanup, refactors, or
follow-up architecture work unless the current diff is incorrect
without that work.
- Prefer the smallest correct pre-merge fix. A broader ideal design is
not an actionable finding unless the current patch cannot safely land.
- If this is release-branch or release-process work, apply freeze
discipline. Report only release blockers, exact backport regressions,
install/upgrade breakage, crashes, data loss, concrete security
exposure, or release-infrastructure failures. Non-blocking design,
cleanup, and hardening concerns belong on main as follow-ups.
"""
).strip()
def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str:
target_line = f"{target} {target_ref}" if target_ref else target
branch = current_branch(repo)
scope_policy = review_scope_policy()
return textwrap.dedent(
f"""
You are a senior code reviewer. Review the provided git change bundle only.
@@ -491,11 +463,8 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
- If there are no actionable findings, return an empty findings array and mark the patch correct.
Review target: {target_line}
Current branch: {branch}
Repository: {repo}
{scope_policy}
{extra_prompt}
{datasets}

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import os
import runpy
import shutil
import stat
import subprocess
@@ -146,23 +145,8 @@ def create_fixture_repo(repo: Path, fixture: str) -> None:
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
def validate_prompt_policy(repo: Path, autoreview: Path) -> None:
namespace = runpy.run_path(str(autoreview))
prompt = namespace["build_prompt"](repo, "local", None, "fixture diff", "", "")
required = (
"This helper is a closeout gate.",
"Do not turn a narrow patch into a broad",
"If this is release-branch or release-process work",
"Non-blocking design,",
)
missing = [needle for needle in required if needle not in prompt]
if missing:
raise RuntimeError(f"autoreview prompt missing scope policy: {missing}")
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
autoreview = script_dir / "autoreview"
validate_prompt_policy(repo, autoreview)
for engine in engines:
print(f"== {engine} ==", flush=True)
command = [

View File

@@ -65,13 +65,6 @@ gh workflow run openclaw-performance.yml \
Prefer the trusted workflow on `main`, target the exact release SHA:
- Keep trusted-workflow checks compatible with frozen release targets. If
`main` adds a target-owned guard script or package command after the release
branch cut, make the trusted workflow skip only when that target surface is
absent. Heal the trusted workflow before rerunning validation; do not port an
unrelated runtime refactor or mutate the release candidate just to satisfy a
newer `main`-only check.
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \

View File

@@ -552,16 +552,6 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- `preflight_only=true` on the npm workflow is also the right way to validate an
existing tag after publish; it should keep running the build checks even when
the npm version is already published.
- npm registry metadata is eventually consistent immediately after trusted
publishing. Keep postpublish `npm view` checks on bounded `--prefer-online`
retries, and carry that verified tarball/integrity metadata into later proof
steps instead of reading the registry again. If the OpenClaw npm child
succeeded but the parent publish workflow failed on an immediate exact-version
`E404`, verify the exact version with a cache-bypassed registry read, run the
standalone postpublish verifier and the full beta verifier with the original
successful child run IDs, then finalize the draft, dependency evidence asset,
and release proof manually. Never rerun the publish workflow for that
already-published version.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.PATCH`.
@@ -730,13 +720,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the first recovery probe:
verifier command remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
For a failed postpublish parent after successful publish children, also run
`pnpm release:verify-beta -- <published-version> ... --skip-github-release`
with the original child run IDs and an evidence output path before manually
recreating the workflow's draft, dependency evidence asset, proof section,
and publish step.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only
important low-risk fixes before starting expensive lanes, or increment to

View File

@@ -1523,13 +1523,7 @@ jobs:
fi
;;
session-transcript-reader-boundary)
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
echo "[skip] session transcript reader boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
echo "[skip] session transcript reader boundary script is not present in package.json"
else
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
fi
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels

View File

@@ -275,7 +275,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count
local before_json dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -298,6 +298,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -307,7 +309,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -408,7 +423,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count
local before_json dispatch_output run_id status conclusion url poll_count
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -431,6 +446,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -440,7 +457,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -551,7 +581,7 @@ jobs:
local workflow="$1"
shift
local dispatch_output run_id status conclusion url poll_count run_json
local before_json dispatch_output run_id status conclusion url poll_count run_json
gh_with_retry() {
local output status attempt
for attempt in 1 2 3 4 5 6; do
@@ -574,6 +604,8 @@ jobs:
printf '%s\n' "$output" >&2
return "$status"
}
before_json="$(gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh_with_retry workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@")"
printf '%s\n' "$dispatch_output"
run_id="$(
@@ -583,7 +615,20 @@ jobs:
)"
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
fi
if [[ -z "${run_id:-}" ]]; then
echo "Could not find dispatched run for ${workflow}." >&2
exit 1
fi
@@ -883,6 +928,8 @@ jobs:
return "$status"
}
before_json="$(gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
@@ -899,16 +946,22 @@ jobs:
args+=(-f scenario="$SCENARIO")
fi
dispatch_output="$(gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}")"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
gh_with_retry workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}"
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow npm-telegram-beta-e2e.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "::error::gh workflow run npm-telegram-beta-e2e.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
exit 1
fi
@@ -1020,23 +1073,31 @@ jobs:
echo "- Release impact: advisory"
} >> "$GITHUB_STEP_SUMMARY"
dispatch_output="$(gh_with_retry workflow run openclaw-performance.yml \
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
gh_with_retry workflow run openclaw-performance.yml \
--ref "$CHILD_WORKFLOW_REF" \
-f target_ref="$TARGET_SHA" \
-f profile=release \
-f repeat=3 \
-f deep_profile=false \
-f live_openai_candidate=false \
-f fail_on_regression=false)"
printf '%s\n' "$dispatch_output"
run_id="$(
printf '%s\n' "$dispatch_output" |
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
tail -n 1
)"
-f fail_on_regression=false
run_id=""
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
fi
sleep 5
done
if [[ -z "$run_id" ]]; then
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
exit 0
fi

View File

@@ -1112,14 +1112,13 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file evidence_path tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
tarball="$(jq -er '.openclawNpmTarball | select(type == "string" and length > 0)' "${evidence_path}")"
integrity="$(jq -er '.openclawNpmIntegrity | select(type == "string" and length > 0)' "${evidence_path}")"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then

View File

@@ -251,6 +251,3 @@ jobs:
- name: Check plugin SDK API baseline drift
run: pnpm plugin-sdk:api:check
- name: Check plugin SDK surface budget
run: pnpm plugin-sdk:surface:check

View File

@@ -1,2 +1,2 @@
ac9d5235efb4880f833565fd67b34722314e20aaa2600eb6e27013b2338dbce5 plugin-sdk-api-baseline.json
a0a90fa7538dddf602f66d8d6d6677fb38f03a90fd5a7a2f0c8e50f2e65f4963 plugin-sdk-api-baseline.jsonl
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl

View File

@@ -418,19 +418,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
</Accordion>
<Accordion title="Rich message formatting">
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
```json5
{
channels: {
telegram: {
richMessages: true,
},
},
}
```
Outbound text uses Telegram rich messages.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
@@ -438,8 +426,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
</Accordion>
@@ -1095,7 +1081,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`)
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`

View File

@@ -54,8 +54,7 @@ doctor can report the missing artifact.
Policy is authored, not generated from the user's current settings. A minimal
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
data-handling posture, config secret provider/auth profile posture, exec approval
file posture, and tool metadata looks like this:
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
```jsonc
{
@@ -146,15 +145,6 @@ file posture, and tool metadata looks like this:
"allowModes": ["api_key", "token"],
},
},
"execApprovals": {
"requireFile": true,
"defaults": { "allowSecurity": ["deny"] },
"agents": {
"allowSecurity": ["deny", "allowlist"],
"allowAutoAllowSkills": false,
"allowlist": { "expected": ["deploy", "status"] },
},
},
"tools": {
"requireMetadata": ["risk", "sensitivity", "owner"],
"profiles": {
@@ -197,11 +187,9 @@ and `group:runtime` covers shell/process tools. Tool posture policy observes
`tools.profile`, `tools.allow`, `tools.alsoAllow`, `tools.deny`,
`tools.fs.workspaceOnly`, `tools.exec.security`, `tools.exec.ask`,
`tools.exec.host`, `tools.elevated.enabled`, and the same per-agent
`agents.list[].tools.*` overrides. Exec approval policy reads the named
`exec-approvals.json` product artifact only when an `execApprovals` rule is
present; evidence records defaults, per-agent posture, and allowlist patterns
without socket tokens or last-used command text. Policy does not enforce tool
calls at runtime. Secret evidence records
`agents.list[].tools.*` overrides. It does not read runtime/operator approval
state such as exec-approvals.json, and it does not enforce tool calls at
runtime. Secret evidence records
provider/source posture and SecretRef metadata, never raw secret values. Policy
does not read or attest per-agent credential stores such as `auth-profiles.json`;
those stores remain owned by the existing auth and credential flows.
@@ -230,8 +218,8 @@ its own finding against the same observed config.
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, `dataHandling.memory.*`,
and `execApprovals.*`. Channel-scoped
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
`dataHandling.memory.*`. Channel-scoped
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
sections are rejected instead of being ignored. If an `agentIds` entry is not
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
@@ -316,10 +304,10 @@ groups where those fields cannot be observed.
Top-level `ingress.session.requireDmScope` remains global because
`session.dmScope` is not channel-attributable evidence.
| Selector | Supported sections | Use when |
| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, `dataHandling.memory`, and `execApprovals` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
| Selector | Supported sections | Use when |
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
Every scope present in `policy.jsonc` must be valid and enforceable.
@@ -413,69 +401,6 @@ allowlist such as `["all"]`.
| `secrets.denySources` | Secret provider sources and SecretRef sources | Deny sources such as `exec`, `file`, or another configured source name. |
| `secrets.allowInsecureProviders` | Insecure secret-provider posture flags | Set to `false` to reject providers that opt into insecure posture. |
#### Exec approvals
Exec approvals policy observes the active runtime `exec-approvals.json`
artifact. By default this is `~/.openclaw/exec-approvals.json`; when
`OPENCLAW_STATE_DIR` is set, Policy reads
`$OPENCLAW_STATE_DIR/exec-approvals.json`. Actual posture rules such as
`execApprovals.defaults.*` or `execApprovals.agents.*` require readable artifact
evidence; a missing or invalid artifact is reported as unobservable evidence
instead of becoming a best-effort pass against synthetic runtime defaults. Once
the artifact is readable, omitted approval fields inherit runtime defaults: missing
`defaults.security` is `full`, and missing agent security inherits that
default. Evidence includes `defaults`, `agents.*`, and
`agents.*.allowlist[].pattern` plus optional `argPattern`, effective
`autoAllowSkills` posture, and entry source. It does not include socket
path/token, `commandText`, `lastUsedCommand`, resolved paths, or timestamps.
| Policy field | Observed state | Use when |
| ------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `execApprovals.requireFile` | Active runtime `exec-approvals.json` path | Set to `true` to require the approvals artifact to exist and parse. |
| `execApprovals.defaults.allowSecurity` | `defaults.security`, defaulting to `full` | Allow only approved default approval security modes. |
| `execApprovals.agents.allowSecurity` | `agents.*.security`, inheriting defaults | Allow only approved per-agent effective approval security modes. |
| `execApprovals.agents.allowAutoAllowSkills` | `defaults.autoAllowSkills` and `agents.*.autoAllowSkills`, inheriting runtime defaults | Set to `false` to require strict manual allowlists without implicit skill CLI approval. |
| `execApprovals.agents.allowlist.expected` | Aggregate `agents.*.allowlist[]` pattern and optional argPattern entries | Require the approvals allowlist to match the reviewed pattern set. |
For example, require the approvals artifact, deny permissive defaults, and
allow only reviewed exec approval posture for selected agents:
```jsonc
{
"execApprovals": {
"requireFile": true,
"defaults": {
// Security modes: "deny", "allowlist", or "full".
// This default permits only the locked-down deny posture.
"allowSecurity": ["deny"],
},
},
"scopes": {
"restricted-shell": {
"agentIds": ["family-agent", "groups-agent"],
"execApprovals": {
"agents": {
// Selected agents may use reviewed allowlist posture, but not "full".
"allowSecurity": ["allowlist"],
// false means skill CLIs must appear in the reviewed allowlist instead of
// being implicitly approved by autoAllowSkills.
"allowAutoAllowSkills": false,
"allowlist": {
"expected": [
// Simple entry: exact reviewed executable pattern with no argPattern.
"travel-hub",
// Constrained entry: pattern plus reviewed argument regex.
{ "pattern": "calendar-cli", "argPattern": "^sync\\b" },
"/bin/date",
],
},
},
},
},
},
}
```
#### Auth profiles
| Policy field | Observed state | Use when |
@@ -844,13 +769,6 @@ Policy currently verifies:
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
| `policy/exec-approvals-missing` | Policy requires `exec-approvals.json`, but the artifact is missing. |
| `policy/exec-approvals-invalid` | The configured exec approvals artifact cannot be parsed. |
| `policy/exec-approvals-default-security-unapproved` | Exec approval defaults use a security mode outside the policy allowlist. |
| `policy/exec-approvals-agent-security-unapproved` | A per-agent effective exec approval security mode is outside the allowlist. |
| `policy/exec-approvals-auto-allow-skills-enabled` | An exec approval agent implicitly auto-allows skill CLIs when policy denies it. |
| `policy/exec-approvals-allowlist-missing` | The approvals allowlist is missing a pattern required by policy. |
| `policy/exec-approvals-allowlist-unexpected` | The approvals allowlist includes a pattern not expected by policy. |
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |

View File

@@ -76,12 +76,6 @@ the root profile before the QA command:
pnpm openclaw --profile work qa run --qa-profile smoke-ci
```
The selected QA profile owns its channel driver. `smoke-ci` uses the internal
host-only Crabline driver for deterministic channel proof; `release` uses the
live driver for release-lane evidence. Direct `qa suite --channel-driver
crabline --channel telegram` runs are maintainer-oriented probes for that same
host driver path.
## Operator flow
The current QA operator flow is a two-pane QA site:

View File

@@ -99,7 +99,7 @@ Optional request headers:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` explicitly controls session routing. The value must not use reserved internal session namespaces such as `subagent:`, `cron:`, or `acp:`; those requests are rejected with `400 invalid_request_error`.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
Compatibility aliases still accepted:
@@ -145,7 +145,7 @@ By default the endpoint is **stateless per request** (a new session key is gener
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` only when you need explicit routing control across multiple clients or threads, and choose application-owned keys that do not start with reserved internal namespaces such as `subagent:`, `cron:`, or `acp:`.
For custom apps, the safest default is to reuse the same `user` value per conversation thread. Avoid account-level identifiers unless you explicitly want multiple conversations or devices to share one OpenClaw session. Use `x-openclaw-session-key` when you need explicit routing control across multiple clients or threads.
## Why this surface matters

View File

@@ -250,7 +250,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
| `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers |

View File

@@ -44,8 +44,10 @@ export function createDiscordDraftPreviewController(params: {
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(params.discordConfig) ??
params.cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamProgressDraftForToolOnlySource =
params.sourceRepliesAreToolOnly && discordStreamMode === "progress";
const canStreamDraft =
!params.sourceRepliesAreToolOnly &&
(!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) &&
discordStreamMode !== "off" &&
!accountBlockStreamingEnabled;
const draftStream = canStreamDraft

View File

@@ -2154,12 +2154,12 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
).toBeUndefined();
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
return createNoQueuedDispatchResult();
@@ -2179,36 +2179,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("preserves explicitly enabled status reactions without exposing tool progress drafts", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(params?.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
cfg: {
tools: { profile: "coding" },
messages: {
ackReaction: "👀",
groupChat: { visibleReplies: "message_tool" },
statusReactions: { enabled: true, timing: { debounceMs: 0 } },
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
});
await runProcessDiscordMessage(ctx);
expect(getReactionEmojis()).toContain(DEFAULT_EMOJIS.done);
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠 Exec\n• exec done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
});

View File

@@ -981,7 +981,9 @@ async function processDiscordMessageInner(
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
suppressTyping: isRoomEvent ? true : undefined,
allowProgressCallbacksWhenSourceDeliverySuppressed:
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
? true
: undefined,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
@@ -999,11 +1001,9 @@ async function processDiscordMessageInner(
? () => draftPreview.handleAssistantMessageBoundary()
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages:
(sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled) ||
draftPreview.suppressDefaultToolProgressMessages
? true
: undefined,
suppressDefaultToolProgressMessages: draftPreview.suppressDefaultToolProgressMessages
? true
: undefined,
commentaryProgressEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,

View File

@@ -51,13 +51,13 @@ describe("createButtonTemplate", () => {
expect((template.template as { text: string }).text.length).toBe(60);
});
it("truncates text to 60 chars when title and thumbnail are provided", () => {
it("keeps longer text when thumbnail is provided", () => {
const longText = "x".repeat(100);
const template = createButtonTemplate("Title", longText, [messageAction("OK")], {
thumbnailImageUrl: "https://example.com/thumb.jpg",
});
expect((template.template as { text: string }).text.length).toBe(60);
expect((template.template as { text: string }).text.length).toBe(100);
});
});
@@ -77,67 +77,12 @@ describe("createCarouselColumn", () => {
expect(column.actions.length).toBe(3);
});
it("truncates text to 120 characters when no title or image is set", () => {
it("truncates text to 120 characters", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({ text: longText, actions: [messageAction("OK")] });
expect(column.text.length).toBe(120);
});
it("truncates text to 60 characters when a title is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
title: "Title",
text: longText,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
it("does not split an emoji grapheme at the 60-code-unit boundary", () => {
const text = `${"x".repeat(59)}👨👩👧👦after`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(59));
});
it("keeps required text when the first grapheme exceeds the limit", () => {
const text = `😀${"\u0301".repeat(59)}`;
const column = createCarouselColumn({
title: "Title",
text,
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
expect(column.text.startsWith("😀")).toBe(true);
});
it("uses the compact limit when a whitespace-only title is present", () => {
const column = createCarouselColumn({
title: " ",
text: "x".repeat(150),
actions: [messageAction("OK")],
});
expect(column.text).toBe("x".repeat(60));
});
it("truncates text to 60 characters when a thumbnail image is set", () => {
const longText = "x".repeat(150);
const column = createCarouselColumn({
text: longText,
thumbnailImageUrl: "https://example.com/thumb.jpg",
actions: [messageAction("OK")],
});
expect(column.text.length).toBe(60);
});
});
describe("carousel column limits", () => {
@@ -186,20 +131,6 @@ describe("createProductCarousel", () => {
.columns;
expect(columns[0].actions[0].type).toBe(expectedType);
});
it("preserves the complete price when truncating a long description", () => {
const template = createProductCarousel([
{
title: "Product",
description: "x".repeat(59),
price: "$12.99",
},
]);
const columns = (template.template as { columns: Array<{ text: string }> }).columns;
expect(columns[0].text).toBe(`${"x".repeat(53)}\n$12.99`);
expect(columns[0].text.length).toBe(60);
});
});
describe("flex cards", () => {

View File

@@ -13,9 +13,6 @@ type CarouselColumn = messagingApi.CarouselColumn;
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
const COMPACT_TEMPLATE_TEXT_LIMIT = 60;
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
type TemplatePayloadAction = {
type?: "uri" | "postback" | "message";
uri?: string;
@@ -33,48 +30,6 @@ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
return messageAction(action.label, action.data ?? action.label);
}
function resolveTemplateTextLimit(params: {
title?: string;
thumbnailImageUrl?: string;
textOnlyLimit: number;
}): number {
return params.title !== undefined || params.thumbnailImageUrl !== undefined
? COMPACT_TEMPLATE_TEXT_LIMIT
: params.textOnlyLimit;
}
function truncateTemplateText(text: string, limit: number): string {
let result = "";
for (const { segment } of graphemeSegmenter.segment(text)) {
if (result.length + segment.length > limit) {
// A pathological grapheme can exceed LINE's whole field limit. Preserve
// graphemes normally, but keep required text non-empty without splitting
// a surrogate pair when the first grapheme alone cannot fit.
if (!result) {
for (const codePoint of segment) {
if (result.length + codePoint.length > limit) {
break;
}
result += codePoint;
}
}
break;
}
result += segment;
}
return result;
}
function formatProductCarouselText(description: string, price?: string): string {
if (!price) {
return description;
}
const priceText = truncateTemplateText(price, COMPACT_TEMPLATE_TEXT_LIMIT);
const descriptionLimit = Math.max(0, COMPACT_TEMPLATE_TEXT_LIMIT - priceText.length - 1);
const descriptionText = truncateTemplateText(description, descriptionLimit);
return descriptionText ? `${descriptionText}\n${priceText}` : priceText;
}
/**
* Create a confirm template (yes/no style dialog)
*/
@@ -113,15 +68,12 @@ export function createButtonTemplate(
altText?: string;
},
): TemplateMessage {
const textLimit = resolveTemplateTextLimit({
title,
thumbnailImageUrl: options?.thumbnailImageUrl,
textOnlyLimit: 160,
});
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
const textLimit = hasThumbnail ? 160 : 60;
const template: ButtonsTemplate = {
type: "buttons",
title: title.slice(0, 40), // LINE limit
text: truncateTemplateText(text, textLimit),
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
actions: actions.slice(0, 4), // LINE limit: max 4 actions
thumbnailImageUrl: options?.thumbnailImageUrl,
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
@@ -173,14 +125,9 @@ export function createCarouselColumn(params: {
imageBackgroundColor?: string;
defaultAction?: Action;
}): CarouselColumn {
// LINE caps a carousel column's text at 60 chars when the column carries a
// title or thumbnail image, and 120 chars otherwise. Sending an over-length
// text makes LINE reject the whole carousel, so mirror the conditional limit
// the buttons template already applies above.
const textLimit = resolveTemplateTextLimit({ ...params, textOnlyLimit: 120 });
return {
title: params.title?.slice(0, 40),
text: truncateTemplateText(params.text, textLimit),
text: params.text.slice(0, 120), // LINE limit
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
thumbnailImageUrl: params.thumbnailImageUrl,
imageBackgroundColor: params.imageBackgroundColor,
@@ -309,7 +256,9 @@ export function createProductCarousel(
return createCarouselColumn({
title: product.title,
text: formatProductCarouselText(product.description, product.price),
text: product.price
? `${product.description}\n${product.price}`.slice(0, 120)
: product.description,
thumbnailImageUrl: product.imageUrl,
actions,
});

View File

@@ -74,14 +74,6 @@ function requireMattermostReplyToModeResolver() {
return resolveReplyToMode;
}
function requireMattermostThreadTargetMatcher() {
const matchesToolContextTarget = mattermostPlugin.threading?.matchesToolContextTarget;
if (!matchesToolContextTarget) {
throw new Error("mattermost threading.matchesToolContextTarget missing");
}
return matchesToolContextTarget;
}
function requireMattermostSendText() {
const sendText = mattermostPlugin.outbound?.sendText;
if (!sendText) {
@@ -244,27 +236,6 @@ describe("mattermostPlugin", () => {
},
);
it("matches bare Mattermost channel ids against the active channel target", () => {
const matchesToolContextTarget = requireMattermostThreadTargetMatcher();
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(true);
expect(
matchesToolContextTarget({
target: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:kqfek9psh7fw8mpa5berwyytqw",
},
}),
).toBe(false);
});
it("exposes the effective reply root as the transport thread", () => {
const resolveReplyTransport = mattermostPlugin.threading?.resolveReplyTransport;
if (!resolveReplyTransport) {
@@ -278,30 +249,8 @@ describe("mattermostPlugin", () => {
threadId: "other-thread",
}),
).toEqual({
replyToId: "other-thread",
threadId: "other-thread",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: true,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
});
expect(
resolveReplyTransport({
cfg: {},
replyToId: "child-post",
replyToIsExplicit: false,
threadId: "root-post",
}),
).toEqual({
replyToId: "root-post",
threadId: "root-post",
replyToId: "post-parent",
threadId: "post-parent",
});
expect(
resolveReplyTransport({
@@ -453,17 +402,6 @@ describe("mattermostPlugin", () => {
},
}),
).toBeUndefined();
expect(
resolveAutoThreadId({
cfg: {},
to: "tqfek9psh7fw8mpa5berwyytqw",
toolContext: {
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
currentThreadTs: "root-1",
replyToMode: "all",
},
}),
).toBe("root-1");
expect(
resolveAutoThreadId({
cfg: {},
@@ -776,7 +714,7 @@ describe("mattermostPlugin", () => {
expect(options.replyToId).toBe("post-root");
});
it("uses threadId as the Mattermost root when generic replyTo names a child post", async () => {
it("keeps explicit reply precedence when threadId is also provided", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
@@ -794,29 +732,7 @@ describe("mattermostPlugin", () => {
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("post-root");
});
it("keeps explicit replyToId precedence when threadId is also provided", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: "explicit-root",
threadId: "post-root",
replyTo: "child-post",
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
expect(options.replyToId).toBe("explicit-root");
expect(options.replyToId).toBe("child-post");
});
it("routes filePath send actions through Mattermost media upload options", async () => {

View File

@@ -258,8 +258,10 @@ function resolveMattermostAutoThreadId(params: {
typeof context?.currentMessageId === "number"
? String(context.currentMessageId)
: normalizeOptionalString(context?.currentMessageId);
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
const currentTarget = context?.currentChannelId
? normalizeMattermostMessagingTarget(context.currentChannelId)
: undefined;
if (currentThreadId && currentTarget === normalizeMattermostMessagingTarget(params.to)) {
if (replyToId === currentMessageId) {
return currentThreadId;
}
@@ -274,28 +276,6 @@ function resolveMattermostAutoThreadId(params: {
return replyToId;
}
function normalizeMattermostThreadTarget(raw: string | undefined): string | undefined {
const normalized = raw ? normalizeMattermostMessagingTarget(raw) : undefined;
if (normalized) {
return normalized;
}
const trimmed = normalizeOptionalString(raw);
return trimmed && /^[a-z0-9]{26}$/i.test(trimmed) ? `channel:${trimmed}` : undefined;
}
function matchesMattermostToolContextTarget(params: {
target: string;
toolContext: ChannelThreadingToolContext;
}): boolean {
const target = normalizeMattermostThreadTarget(params.target);
if (!target) {
return false;
}
return [params.toolContext.currentChannelId, params.toolContext.currentMessagingTarget].some(
(currentTarget) => normalizeMattermostThreadTarget(currentTarget) === target,
);
}
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
}
@@ -440,13 +420,12 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
: typeof params.message === "string"
? params.message
: "";
// Mattermost post root_id is the thread root. A generic replyTo can name
// the current child post, so prefer threadId unless the caller supplied the
// Mattermost-specific replyToId root directly.
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
const replyToId =
normalizeOptionalString(params.replyToId) ??
normalizeOptionalString(params.threadId) ??
normalizeOptionalString(params.replyTo);
normalizeOptionalString(params.replyTo) ??
normalizeOptionalString(params.threadId);
const resolvedAccountId = accountId || undefined;
const attachmentMedia = collectMattermostAttachmentMedia(params);
@@ -917,18 +896,16 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
},
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
matchesToolContextTarget: ({ target, toolContext }) =>
matchesMattermostToolContextTarget({ target, toolContext }),
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyDelivery
? replyToIsExplicit
? (replyToId ?? ambientThreadId)
: (ambientThreadId ?? replyToId ?? undefined)
: (ambientThreadId ?? replyToId);
: replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
return {
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,

View File

@@ -2,10 +2,6 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import {
acquireMemoryReindexSwapLock,
type MemoryReindexLockHandle,
} from "./manager-reindex-lock.js";
type MemoryIndexFileOps = {
rename: typeof fs.rename;
@@ -30,13 +26,6 @@ const defaultFileOps: MemoryIndexFileOps = {
};
const transientFileErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
// SQLite keeps WAL/SHM sidecars under journal_mode=WAL, but NFS-backed stores
// fall back to journal_mode=DELETE and leave a rollback-journal (-journal)
// sidecar instead. Index file operations must cover all three so a swap never
// strands a stale -journal next to the freshly published database, which would
// trigger an erroneous rollback the next time SQLite opens the index.
const memoryIndexFileSuffixes = ["", "-wal", "-shm", "-journal"] as const;
const memoryIndexSidecarSuffixes = ["-wal", "-shm", "-journal"] as const;
const defaultMaxRenameAttempts = 6;
const defaultRenameRetryDelayMs = 25;
const defaultMaxRemoveAttempts = 10;
@@ -87,7 +76,8 @@ export async function moveMemoryIndexFiles(
options: MemoryIndexFileOptions = {},
): Promise<void> {
const resolvedOptions = resolveMemoryIndexFileOptions(options);
for (const suffix of memoryIndexFileSuffixes) {
const suffixes = ["", "-wal", "-shm"];
for (const suffix of suffixes) {
const source = `${sourceBase}${suffix}`;
const target = `${targetBase}${suffix}`;
await renameWithRetry(source, target, resolvedOptions, suffix !== "");
@@ -117,7 +107,8 @@ export async function removeMemoryIndexFiles(
options: MemoryIndexFileOptions = {},
): Promise<void> {
const resolvedOptions = resolveMemoryIndexFileOptions(options);
for (const suffix of memoryIndexFileSuffixes) {
const suffixes = ["", "-wal", "-shm"];
for (const suffix of suffixes) {
await rmWithRetry(`${basePath}${suffix}`, resolvedOptions);
}
}
@@ -126,9 +117,8 @@ async function removeMemoryIndexSidecars(
basePath: string,
options: ResolvedMemoryIndexFileOptions,
): Promise<void> {
for (const suffix of memoryIndexSidecarSuffixes) {
await rmWithRetry(`${basePath}${suffix}`, options);
}
await rmWithRetry(`${basePath}-wal`, options);
await rmWithRetry(`${basePath}-shm`, options);
}
async function moveMemoryIndexSidecars(
@@ -136,7 +126,8 @@ async function moveMemoryIndexSidecars(
targetBase: string,
options: ResolvedMemoryIndexFileOptions,
): Promise<void> {
for (const suffix of memoryIndexSidecarSuffixes) {
const suffixes = ["-wal", "-shm"];
for (const suffix of suffixes) {
await renameWithRetry(`${sourceBase}${suffix}`, `${targetBase}${suffix}`, options, true);
}
}
@@ -228,9 +219,7 @@ export async function runMemoryAtomicReindex<T>(params: {
afterPublish?: () => Promise<void> | void;
fileOptions?: MemoryIndexFileOptions;
}): Promise<T> {
let swapLock: MemoryReindexLockHandle | undefined;
try {
swapLock = acquireMemoryReindexSwapLock(params.targetPath);
const result = await params.build();
await swapMemoryIndexFiles(
params.targetPath,
@@ -252,7 +241,5 @@ export async function runMemoryAtomicReindex<T>(params: {
throw aggregateErr;
}
throw err;
} finally {
swapLock?.release();
}
}

View File

@@ -6,15 +6,12 @@ import { DatabaseSync } from "node:sqlite";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
cleanupAgedMemoryReindexTempFiles,
closeMemoryDatabase,
openMemoryDatabaseAtPath,
openMemoryReindexTempDatabaseAtPath,
} from "./manager-db.js";
import {
acquireMemoryReindexSwapLock,
acquireMemoryReindexLock,
resolveMemoryReindexLockPath,
tryAcquireMemoryReindexSwapLock,
tryAcquireMemoryReindexLock,
} from "./manager-reindex-lock.js";
@@ -22,17 +19,6 @@ async function expectPathMissing(targetPath: string): Promise<void> {
await expect(fs.access(targetPath)).rejects.toThrow("ENOENT");
}
function listOpenFileDescriptorsForPath(targetPath: string): string[] {
return fsSync.readdirSync("/proc/self/fd").flatMap((fd) => {
try {
const descriptorPath = fsSync.readlinkSync(`/proc/self/fd/${fd}`);
return descriptorPath.startsWith(targetPath) ? [descriptorPath] : [];
} catch {
return [];
}
});
}
describe("openMemoryDatabaseAtPath readOnly probe", () => {
let fixtureRoot = "";
let caseId = 0;
@@ -59,7 +45,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
const db = openMemoryDatabaseAtPath(dbPath, false);
expect(db).toBeDefined();
closeMemoryDatabase(db);
db.close();
});
it("allows creating a new database when allowCreate is true", async () => {
@@ -67,25 +53,12 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
const db = openMemoryDatabaseAtPath(dbPath, false, true);
expect(db).toBeDefined();
closeMemoryDatabase(db);
db.close();
const stat = await fs.stat(dbPath);
expect(stat.size).toBeGreaterThan(0);
});
it.skipIf(process.platform !== "linux")(
"closes the database when SQLite maintenance configuration fails",
async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "malformed-index.sqlite");
await fs.mkdir(path.dirname(dbPath), { recursive: true });
await fs.writeFile(dbPath, "not a sqlite database");
expect(() => openMemoryDatabaseAtPath(dbPath, false, false)).toThrow(/not a database/);
expect(listOpenFileDescriptorsForPath(dbPath)).toEqual([]);
},
);
it("refuses to create a missing live database while a safe reindex holds the lock", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
await fs.mkdir(path.dirname(dbPath), { recursive: true });
@@ -98,7 +71,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
reindexLock.release();
const db = openMemoryDatabaseAtPath(dbPath, false, true);
closeMemoryDatabase(db);
db.close();
});
it("refuses to auto-create an empty database when allowCreate is false", async () => {
@@ -121,7 +94,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
const reopen = openMemoryDatabaseAtPath(dbPath, false, false);
expect(reopen).toBeDefined();
closeMemoryDatabase(reopen);
reopen.close();
});
it("removes aged orphan reindex temp files before opening the live database", async () => {
@@ -141,7 +114,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
}
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
await expectPathMissing(orphanBase);
await expectPathMissing(`${orphanBase}-wal`);
@@ -168,7 +141,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
}
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
await expectPathMissing(orphanBase);
await expectPathMissing(`${orphanBase}-journal`);
@@ -191,7 +164,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
await fs.utimes(strandedJournal, old, old);
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
await expectPathMissing(strandedJournal);
});
@@ -210,7 +183,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
}
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
await expect(fs.access(activeBase)).resolves.toBeUndefined();
await expect(fs.access(`${activeBase}-wal`)).resolves.toBeUndefined();
@@ -236,7 +209,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
cleanupAgedMemoryReindexTempFiles(dbPath);
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
await expect(fs.access(activeBase)).resolves.toBeUndefined();
await expect(fs.access(`${activeBase}-wal`)).resolves.toBeUndefined();
@@ -257,7 +230,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
await fs.utimes(orphanBase, old, old);
const db = openMemoryDatabaseAtPath(dbPath, false, true);
closeMemoryDatabase(db);
db.close();
await expect(fs.access(orphanBase)).resolves.toBeUndefined();
});
@@ -278,36 +251,6 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
await expect(fs.access(resolveMemoryReindexLockPath(dbPath))).resolves.toBeUndefined();
});
it("blocks an atomic swap while a live memory database is open", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
await fs.mkdir(path.dirname(dbPath), { recursive: true });
const seed = new DatabaseSync(dbPath);
seed.close();
const db = openMemoryDatabaseAtPath(dbPath, false);
expect(tryAcquireMemoryReindexSwapLock(dbPath)).toBeUndefined();
closeMemoryDatabase(db);
const swapLock = acquireMemoryReindexSwapLock(dbPath);
swapLock.release();
});
it("blocks a live database open while an atomic swap is active", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
await fs.mkdir(path.dirname(dbPath), { recursive: true });
const seed = new DatabaseSync(dbPath);
seed.close();
const swapLock = acquireMemoryReindexSwapLock(dbPath);
expect(() => openMemoryDatabaseAtPath(dbPath, false)).toThrow(
/unavailable during a safe reindex swap/,
);
swapLock.release();
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
});
it("does not block database startup when orphan discovery fails", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
await fs.mkdir(path.dirname(dbPath), { recursive: true });
@@ -318,6 +261,6 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
});
const db = openMemoryDatabaseAtPath(dbPath, false);
closeMemoryDatabase(db);
db.close();
});
});

View File

@@ -9,7 +9,6 @@ import {
requireNodeSqlite,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
acquireMemoryReindexSwapReadLock,
acquireMemoryReindexLock,
tryAcquireMemoryReindexLock,
type MemoryReindexLockHandle,
@@ -22,7 +21,6 @@ const reindexTempFileWithoutLockMinAgeMs = 24 * 60 * 60_000;
const reindexTempUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const memoryIndexFileSuffixes = ["", "-wal", "-shm", "-journal"] as const;
const reindexTempEntrySuffixes = ["-wal", "-shm", "-journal", ""] as const;
const liveDatabaseSwapLocks = new WeakMap<DatabaseSync, MemoryReindexLockHandle>();
function resolveReindexTempBaseName(dbBaseName: string, entryName: string): string | undefined {
for (const suffix of reindexTempEntrySuffixes) {
@@ -129,18 +127,11 @@ export function cleanupAgedMemoryReindexTempFiles(dbPath: string, nowMs = Date.n
function openConfiguredMemoryDatabaseAtPath(dbPath: string, allowExtension: boolean): DatabaseSync {
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(dbPath, { allowExtension });
try {
configureMemorySqliteWalMaintenance(db, {
busyTimeoutMs: 5000,
databasePath: dbPath,
});
return db;
} catch (err) {
try {
db.close();
} catch {}
throw err;
}
configureMemorySqliteWalMaintenance(db, {
busyTimeoutMs: 5000,
databasePath: dbPath,
});
return db;
}
type ExistingMemoryDatabaseOpenResult =
@@ -195,50 +186,40 @@ export function openMemoryDatabaseAtPath(
const dir = path.dirname(dbPath);
ensureDir(dir);
cleanupAgedMemoryReindexTempFiles(dbPath);
const swapReadLock = acquireMemoryReindexSwapReadLock(dbPath);
try {
const existing = tryOpenExistingMemoryDatabaseAtPath(dbPath, allowExtension);
if (existing.status === "opened") {
liveDatabaseSwapLocks.set(existing.db, swapReadLock);
return existing.db;
}
if (!allowCreate) {
throw new Error(
`Memory database not found at ${dbPath}; refusing to auto-create an empty database during an index swap window.`,
{ cause: existing.cause },
);
}
const existing = tryOpenExistingMemoryDatabaseAtPath(dbPath, allowExtension);
if (existing.status === "opened") {
return existing.db;
}
if (!allowCreate) {
throw new Error(
`Memory database not found at ${dbPath}; refusing to auto-create an empty database during an index swap window.`,
{ cause: existing.cause },
);
}
// A missing canonical path can be an initial create or the Windows swap
// window. Only the safe-reindex owner may create or publish during that gap.
const openLock = acquireMemoryReindexLock(dbPath);
let db: DatabaseSync;
try {
const lockedExisting = tryOpenExistingMemoryDatabaseAtPath(dbPath, allowExtension);
db =
lockedExisting.status === "opened"
? lockedExisting.db
: openConfiguredMemoryDatabaseAtPath(dbPath, allowExtension);
} catch (err) {
try {
openLock.release();
} catch {}
throw err;
}
try {
openLock.release();
} catch (err) {
closeMemoryDatabase(db);
throw err;
}
liveDatabaseSwapLocks.set(db, swapReadLock);
return db;
// A missing canonical path can be an initial create or the Windows swap
// window. Only the safe-reindex owner may create or publish during that gap.
const openLock = acquireMemoryReindexLock(dbPath);
let db: DatabaseSync;
try {
const lockedExisting = tryOpenExistingMemoryDatabaseAtPath(dbPath, allowExtension);
db =
lockedExisting.status === "opened"
? lockedExisting.db
: openConfiguredMemoryDatabaseAtPath(dbPath, allowExtension);
} catch (err) {
try {
swapReadLock.release();
openLock.release();
} catch {}
throw err;
}
try {
openLock.release();
} catch (err) {
closeMemoryDatabase(db);
throw err;
}
return db;
}
export function openMemoryReindexTempDatabaseAtPath(
@@ -252,19 +233,4 @@ export function openMemoryReindexTempDatabaseAtPath(
export function closeMemoryDatabase(db: DatabaseSync): void {
closeMemorySqliteWalMaintenance(db);
db.close();
releaseMemoryDatabaseSwapLock(db);
}
export function releaseMemoryDatabaseSwapLock(db: DatabaseSync): void {
const swapLock = liveDatabaseSwapLocks.get(db);
if (swapLock) {
liveDatabaseSwapLocks.delete(db);
swapLock.release();
}
}
export function restoreMemoryDatabaseSwapLock(db: DatabaseSync, dbPath: string): void {
if (!liveDatabaseSwapLocks.has(db)) {
liveDatabaseSwapLocks.set(db, acquireMemoryReindexSwapReadLock(dbPath));
}
}

View File

@@ -1,6 +1,6 @@
// Memory Core plugin module implements cross-process safe-reindex locking.
// Dedicated sibling DBs follow custom store paths and rely on SQLite to release
// shared/exclusive transactions automatically after process/container death.
// The dedicated sibling DB follows custom store paths and relies on SQLite to
// release its exclusive transaction automatically after process/container death.
import type { DatabaseSync } from "node:sqlite";
import { requireNodeSqlite } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
@@ -12,11 +12,6 @@ export function resolveMemoryReindexLockPath(dbPath: string): string {
return `${dbPath}.reindex-lock.sqlite`;
}
export function resolveMemoryReindexSwapLockPath(dbPath: string): string {
// This sibling contains coordination state only, never memory index data.
return `${dbPath}.reindex-swap-lock`;
}
function isSqliteBusyError(err: unknown): boolean {
const code = (err as { code?: unknown }).code;
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
@@ -26,7 +21,8 @@ function isSqliteBusyError(err: unknown): boolean {
return /SQLITE_(?:BUSY|LOCKED)|database is locked/i.test(message);
}
function openMemoryLockDatabase(lockPath: string): DatabaseSync {
function openMemoryReindexLockDatabase(dbPath: string): DatabaseSync {
const lockPath = resolveMemoryReindexLockPath(dbPath);
const { DatabaseSync } = requireNodeSqlite();
const lockDb = new DatabaseSync(lockPath);
try {
@@ -40,7 +36,19 @@ function openMemoryLockDatabase(lockPath: string): DatabaseSync {
}
}
function createMemoryLockHandle(lockDb: DatabaseSync, label: string): MemoryReindexLockHandle {
export function tryAcquireMemoryReindexLock(dbPath: string): MemoryReindexLockHandle | undefined {
const lockDb = openMemoryReindexLockDatabase(dbPath);
try {
// SQLite releases this transaction automatically when a process or
// container dies, so ownership never depends on PID namespaces or leases.
lockDb.exec("BEGIN EXCLUSIVE");
} catch (err) {
lockDb.close();
if (isSqliteBusyError(err)) {
return undefined;
}
throw err;
}
return {
release: () => {
let releaseError: unknown;
@@ -55,35 +63,12 @@ function createMemoryLockHandle(lockDb: DatabaseSync, label: string): MemoryRein
releaseError ??= err;
}
if (releaseError) {
throw new Error(`Failed to release ${label}`, { cause: releaseError });
throw new Error("Failed to release memory reindex lock", { cause: releaseError });
}
},
};
}
function tryAcquireMemoryExclusiveLock(
lockPath: string,
label: string,
): MemoryReindexLockHandle | undefined {
const lockDb = openMemoryLockDatabase(lockPath);
try {
// SQLite releases this transaction automatically when a process or
// container dies, so ownership never depends on PID namespaces or leases.
lockDb.exec("BEGIN EXCLUSIVE");
} catch (err) {
lockDb.close();
if (isSqliteBusyError(err)) {
return undefined;
}
throw err;
}
return createMemoryLockHandle(lockDb, label);
}
export function tryAcquireMemoryReindexLock(dbPath: string): MemoryReindexLockHandle | undefined {
return tryAcquireMemoryExclusiveLock(resolveMemoryReindexLockPath(dbPath), "memory reindex lock");
}
export function acquireMemoryReindexLock(dbPath: string): MemoryReindexLockHandle {
const lock = tryAcquireMemoryReindexLock(dbPath);
if (lock) {
@@ -96,46 +81,3 @@ export function acquireMemoryReindexLock(dbPath: string): MemoryReindexLockHandl
{ code: "SQLITE_BUSY" },
);
}
export function acquireMemoryReindexSwapReadLock(dbPath: string): MemoryReindexLockHandle {
const lockDb = openMemoryLockDatabase(resolveMemoryReindexSwapLockPath(dbPath));
try {
// A deferred transaction only takes a shared lock after its first read.
lockDb.exec("BEGIN");
lockDb.prepare("SELECT name FROM sqlite_schema LIMIT 1").get();
} catch (err) {
lockDb.close();
if (isSqliteBusyError(err)) {
throw Object.assign(
new Error(`Memory database at ${dbPath} is unavailable during a safe reindex swap.`, {
cause: err,
}),
{ code: "SQLITE_BUSY" },
);
}
throw err;
}
return createMemoryLockHandle(lockDb, "memory reindex swap read lock");
}
export function tryAcquireMemoryReindexSwapLock(
dbPath: string,
): MemoryReindexLockHandle | undefined {
return tryAcquireMemoryExclusiveLock(
resolveMemoryReindexSwapLockPath(dbPath),
"memory reindex swap lock",
);
}
export function acquireMemoryReindexSwapLock(dbPath: string): MemoryReindexLockHandle {
const lock = tryAcquireMemoryReindexSwapLock(dbPath);
if (lock) {
return lock;
}
throw Object.assign(
new Error(
`Cannot publish memory reindex for ${dbPath}; another process is using the live database.`,
),
{ code: "SQLITE_BUSY" },
);
}

View File

@@ -52,8 +52,6 @@ import {
closeMemoryDatabase,
openMemoryDatabaseAtPath,
openMemoryReindexTempDatabaseAtPath,
releaseMemoryDatabaseSwapLock,
restoreMemoryDatabaseSwapLock,
} from "./manager-db.js";
import { isMemoryEmbeddingOperationError } from "./manager-embedding-errors.js";
import {
@@ -2433,7 +2431,6 @@ export abstract class MemoryManagerSyncOps {
let tempDb: DatabaseSync | undefined;
let tempDbClosed = false;
let originalDbClosed = false;
let originalDbSwapLockReleased = false;
const originalRetryState = this.snapshotReindexRetryState();
const shouldRetryMemoryOnFailure = this.sources.has("memory");
const shouldRetrySessionsOnFailure = this.shouldSyncSessions(
@@ -2454,10 +2451,6 @@ export abstract class MemoryManagerSyncOps {
if (originalDbClosed) {
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled, false);
} else {
if (originalDbSwapLockReleased) {
restoreMemoryDatabaseSwapLock(originalDb, dbPath);
originalDbSwapLockReleased = false;
}
this.db = originalDb;
}
this.fts.available = originalState.ftsAvailable;
@@ -2484,8 +2477,6 @@ export abstract class MemoryManagerSyncOps {
this.fts.loadError = undefined;
this.ensureSchema();
originalDbSwapLockReleased = true;
releaseMemoryDatabaseSwapLock(originalDb);
const nextMeta = await runMemoryAtomicReindex({
targetPath: dbPath,
tempPath: tempDbPath,

View File

@@ -1,9 +1,7 @@
// Memory Core tests cover manager.atomic reindex plugin behavior.
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import { DatabaseSync } from "node:sqlite";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -12,8 +10,6 @@ import {
runMemoryAtomicReindex,
} from "./manager-atomic-reindex.js";
const managerDbModuleUrl = new URL("./manager-db.ts", import.meta.url).href;
async function expectPathMissing(targetPath: string): Promise<void> {
await expectRejectCode(fs.access(targetPath), "ENOENT");
}
@@ -37,138 +33,6 @@ function normalizeBackupName(filePath: string): string {
);
}
type PeerWriter = {
commit: () => Promise<void>;
close: () => Promise<void>;
};
async function attemptPeerCommit(dbPath: string): Promise<"blocked" | "committed"> {
const script = `
import {
closeMemoryDatabase,
openMemoryDatabaseAtPath,
} from ${JSON.stringify(managerDbModuleUrl)};
const [dbPath] = process.argv.slice(1);
let db;
try {
db = openMemoryDatabaseAtPath(dbPath, false);
db.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0");
db.exec("CREATE TABLE peer_commits (id TEXT PRIMARY KEY)");
db.prepare("INSERT INTO peer_commits (id) VALUES (?)").run("acknowledged");
process.stdout.write("committed\\n");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
if (/SQLITE_(?:BUSY|LOCKED)|database is locked/i.test(\`\${code} \${message}\`)) {
process.stdout.write("blocked\\n");
} else {
console.error(message);
process.exitCode = 1;
}
} finally {
if (db) closeMemoryDatabase(db);
}
`;
const child = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", script, dbPath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
await waitForChildExit(child, () => stderr);
const result = stdout.trim();
if (result !== "blocked" && result !== "committed") {
throw new Error(`unexpected peer commit result: ${result}`);
}
return result;
}
async function startPeerWriter(dbPath: string): Promise<PeerWriter> {
const script = `
import {
closeMemoryDatabase,
openMemoryDatabaseAtPath,
} from ${JSON.stringify(managerDbModuleUrl)};
const [dbPath] = process.argv.slice(1);
const db = openMemoryDatabaseAtPath(dbPath, false);
db.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0");
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
for (;;) {
const newline = input.indexOf("\\n");
if (newline < 0) break;
const command = input.slice(0, newline);
input = input.slice(newline + 1);
if (command === "commit") {
db.exec("CREATE TABLE peer_commits (id TEXT PRIMARY KEY)");
db.prepare("INSERT INTO peer_commits (id) VALUES (?)").run("acknowledged");
process.stdout.write("committed\\n");
} else if (command === "close") {
closeMemoryDatabase(db);
}
}
});
process.stdout.write("ready\\n");
`;
const child = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", script, dbPath],
{ stdio: ["pipe", "pipe", "pipe"] },
);
const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator]();
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const expectLine = async (expected: string): Promise<void> => {
const next = await lines.next();
if (next.done || next.value !== expected) {
throw new Error(`peer writer expected ${expected}, got ${String(next.value)}: ${stderr}`);
}
};
await expectLine("ready");
return {
commit: async () => {
child.stdin.write("commit\n");
await expectLine("committed");
},
close: async () => {
child.stdin.end("close\n");
await waitForChildExit(child, () => stderr);
},
};
}
async function waitForChildExit(child: ChildProcess, getStderr: () => string): Promise<void> {
if (child.exitCode !== null) {
expect(child.exitCode, getStderr()).toBe(0);
return;
}
await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => {
try {
expect(code, getStderr()).toBe(0);
resolve();
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
});
});
}
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
let caseId = 0;
@@ -222,48 +86,6 @@ describe("memory manager atomic reindex", () => {
await expectPathMissing(tempIndexPath);
});
it("refuses to publish while a peer owns an acknowledged live write", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
const peer = await startPeerWriter(indexPath);
try {
await peer.commit();
expect((await fs.stat(`${indexPath}-wal`)).size).toBeGreaterThan(0);
await expect(
runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
build: async () => undefined,
}),
).rejects.toThrow(/another process is using the live database/);
expect(readChunkMarker(indexPath)).toBe("before");
expect(readPeerCommit(indexPath)).toBe("acknowledged");
expect(readIntegrityCheck(indexPath)).toBe("ok");
await expectPathMissing(tempIndexPath);
} finally {
await peer.close();
}
});
it("blocks peer commits beyond the final temp checkpoint", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
let peerCommit: "blocked" | "committed" | undefined;
await runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
build: async () => {
peerCommit = await attemptPeerCommit(indexPath);
},
});
expect(peerCommit).toBe("blocked");
expect(readChunkMarker(indexPath)).toBe("after");
});
it("retries transient rename failures during index swaps", async () => {
const rename = vi
.fn()
@@ -277,8 +99,7 @@ describe("memory manager atomic reindex", () => {
renameRetryDelayMs: 10,
});
// main (1 retry) + -wal + -shm + -journal.
expect(rename).toHaveBeenCalledTimes(5);
expect(rename).toHaveBeenCalledTimes(4);
expect(wait).toHaveBeenCalledTimes(1);
expect(wait).toHaveBeenCalledWith(10);
});
@@ -307,8 +128,7 @@ describe("memory manager atomic reindex", () => {
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(Object.assign(new Error("missing wal"), { code: "ENOENT" }))
.mockRejectedValueOnce(Object.assign(new Error("missing shm"), { code: "ENOENT" }))
.mockRejectedValueOnce(Object.assign(new Error("missing journal"), { code: "ENOENT" }));
.mockRejectedValueOnce(Object.assign(new Error("missing shm"), { code: "ENOENT" }));
const wait = vi.fn().mockResolvedValue(undefined);
await moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
@@ -317,8 +137,7 @@ describe("memory manager atomic reindex", () => {
renameRetryDelayMs: 10,
});
// main + the three optional sidecars (-wal, -shm, -journal), none retried.
expect(rename).toHaveBeenCalledTimes(4);
expect(rename).toHaveBeenCalledTimes(3);
expect(wait).not.toHaveBeenCalled();
});
@@ -383,7 +202,6 @@ describe("memory manager atomic reindex", () => {
"index.sqlite.tmp",
"index.sqlite.tmp-wal",
"index.sqlite.tmp-shm",
"index.sqlite.tmp-journal",
]);
expect(wait).toHaveBeenCalledTimes(1);
expect(wait).toHaveBeenCalledWith(10);
@@ -430,14 +248,13 @@ describe("memory manager atomic reindex", () => {
const events: string[] = [];
let tempClosed = false;
const rm: typeof fs.rm = vi.fn(async (filePath) => {
const entryName = path.basename(String(filePath));
events.push(tempClosed ? `rm:${entryName}:closed` : `rm:${entryName}:open`);
events.push(tempClosed ? `rm:${String(filePath)}:closed` : `rm:${String(filePath)}:open`);
});
await expect(
runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
targetPath: "index.sqlite",
tempPath: "index.sqlite.tmp",
beforeTempCleanup: async () => {
events.push("close-temp");
tempClosed = true;
@@ -456,7 +273,6 @@ describe("memory manager atomic reindex", () => {
"rm:index.sqlite.tmp:closed",
"rm:index.sqlite.tmp-wal:closed",
"rm:index.sqlite.tmp-shm:closed",
"rm:index.sqlite.tmp-journal:closed",
]);
});
@@ -497,10 +313,8 @@ describe("memory manager atomic reindex", () => {
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${indexPath}-wal`, "stale wal");
await fs.writeFile(`${indexPath}-shm`, "stale shm");
await fs.writeFile(`${indexPath}-journal`, "stale journal");
await fs.writeFile(`${tempIndexPath}-wal`, "closed temp wal");
await fs.writeFile(`${tempIndexPath}-shm`, "closed temp shm");
await fs.writeFile(`${tempIndexPath}-journal`, "closed temp journal");
const events: string[] = [];
const realRename = fs.rename;
@@ -526,90 +340,21 @@ describe("memory manager atomic reindex", () => {
});
expect(readChunkMarker(indexPath)).toBe("after");
expect(rename).toHaveBeenCalledTimes(4);
expect(rename).toHaveBeenCalledTimes(3);
expect(events).toEqual([
"rename:index.sqlite-wal->index.sqlite.backup-<uuid>-wal",
"rename:index.sqlite-shm->index.sqlite.backup-<uuid>-shm",
"rename:index.sqlite-journal->index.sqlite.backup-<uuid>-journal",
"rename:index.sqlite.tmp->index.sqlite",
"rm:index.sqlite.backup-<uuid>:after",
"rm:index.sqlite.backup-<uuid>-wal:after",
"rm:index.sqlite.backup-<uuid>-shm:after",
"rm:index.sqlite.backup-<uuid>-journal:after",
"rm:index.sqlite.tmp-wal:after",
"rm:index.sqlite.tmp-shm:after",
"rm:index.sqlite.tmp-journal:after",
]);
await expectPathMissing(`${indexPath}-wal`);
await expectPathMissing(`${indexPath}-shm`);
await expectPathMissing(`${indexPath}-journal`);
await expectPathMissing(`${tempIndexPath}-wal`);
await expectPathMissing(`${tempIndexPath}-shm`);
await expectPathMissing(`${tempIndexPath}-journal`);
});
it("does not strand a stale rollback-journal next to the published index", async () => {
// journal_mode=DELETE stores (e.g. NFS-backed) leave a -journal sidecar
// instead of -wal/-shm. A swap that ignores it would publish the new main
// file beside a stale rollback journal, so the next open would roll the
// fresh index back to a torn state. The journal must be cleared on publish.
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${indexPath}-journal`, "stale rollback journal");
await runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
build: async () => undefined,
});
// Real disk readback across the swap boundary.
expect(readChunkMarker(indexPath)).toBe("after");
await expectPathMissing(`${indexPath}-journal`);
});
it("removes the temp rollback-journal sidecar when a reindex build fails", async () => {
// A crashed/failed reindex on a DELETE-mode store can leave a temp
// -journal sidecar. Cleanup must remove it alongside the temp main file so
// the startup orphan sweep is never required to reclaim it.
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${tempIndexPath}-journal`, "temp rollback journal");
await expect(
runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
build: async () => {
throw new Error("embedding failure");
},
}),
).rejects.toThrow("embedding failure");
// The prior index survives and the temp triplet (incl. -journal) is gone.
expect(readChunkMarker(indexPath)).toBe("before");
await expectPathMissing(tempIndexPath);
await expectPathMissing(`${tempIndexPath}-journal`);
});
it("moves the rollback-journal sidecar with the main index across the real filesystem", async () => {
// moveMemoryIndexFiles is the Windows backup-protocol restore primitive.
// It must carry the -journal sidecar so a DELETE-mode index is recovered
// intact when a publish is rolled back.
const sourceBase = `${indexPath}.tmp`;
writeChunkMarker(sourceBase, "recovered");
await fs.writeFile(`${sourceBase}-journal`, "recovered journal");
await moveMemoryIndexFiles(sourceBase, indexPath);
// Real disk readback at the destination. Inspect the relocated journal
// before opening the DB, since opening index.sqlite would treat a sibling
// -journal as a hot journal and consume it.
await expect(fs.readFile(`${indexPath}-journal`, "utf8")).resolves.toBe("recovered journal");
await expectPathMissing(sourceBase);
await expectPathMissing(`${sourceBase}-journal`);
await fs.rm(`${indexPath}-journal`, { force: true });
expect(readChunkMarker(indexPath)).toBe("recovered");
});
it("reports publish before post-swap cleanup failures", async () => {
@@ -733,26 +478,3 @@ function readChunkMarker(dbPath: string): string | undefined {
db.close();
}
}
function readPeerCommit(dbPath: string): string | undefined {
const db = new DatabaseSync(dbPath);
try {
return (
db.prepare("SELECT id FROM peer_commits WHERE id = ?").get("acknowledged") as
| { id: string }
| undefined
)?.id;
} finally {
db.close();
}
}
function readIntegrityCheck(dbPath: string): string | undefined {
const db = new DatabaseSync(dbPath);
try {
return (db.prepare("PRAGMA integrity_check").get() as { integrity_check?: string } | undefined)
?.integrity_check;
} finally {
db.close();
}
}

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeMemoryDatabase, openMemoryDatabaseAtPath } from "./manager-db.js";
import { openMemoryDatabaseAtPath } from "./manager-db.js";
import {
enqueueMemoryTargetedSessionSync,
runMemorySyncWithReadonlyRecovery,
@@ -214,7 +214,7 @@ describe("memory manager readonly recovery", () => {
| undefined;
const busyTimeout = row?.busy_timeout ?? row?.timeout;
expect(busyTimeout).toBe(5000);
closeMemoryDatabase(db);
db.close();
});
it("queues targeted session files behind an in-flight sync", async () => {

View File

@@ -7,10 +7,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbedBatchMock, resetEmbeddingMocks } from "./embedding.test-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import {
acquireMemoryReindexSwapReadLock,
tryAcquireMemoryReindexSwapLock,
} from "./manager-reindex-lock.js";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
type SessionDeltaState = { lastSize: number; pendingBytes: number; pendingMessages: number };
@@ -241,51 +237,6 @@ describe("memory manager reindex recovery", () => {
expect(harness.sessionsFullRetryDirty).toBe(false);
});
it("restores the live database guard after a peer blocks safe reindex", async () => {
const storePath = path.join(workspaceDir, "index-peer-contention.sqlite");
const memoryManager = await openManager(
createCfg({
storePath,
provider: "none",
sources: ["memory"],
}),
);
const harness = memoryManager as unknown as ReindexHarness;
const peerLock = acquireMemoryReindexSwapReadLock(storePath);
try {
await expect(harness.runSafeReindex({ reason: "test", force: true })).rejects.toThrow(
/another process is using the live database/,
);
} finally {
peerLock.release();
}
const exclusiveLock = tryAcquireMemoryReindexSwapLock(storePath);
expect(exclusiveLock).toBeUndefined();
exclusiveLock?.release();
expect(harness.db.prepare("SELECT 1 AS ok").get()).toEqual({ ok: 1 });
});
it("releases the live database guard after constructor schema failure", async () => {
const storePath = path.join(workspaceDir, "index-incompatible-schema.sqlite");
const db = new DatabaseSync(storePath);
db.exec("CREATE TABLE chunks (id TEXT PRIMARY KEY)");
db.close();
const { getMemorySearchManager } = await import("./index.js");
const result = await getMemorySearchManager({
cfg: createCfg({ storePath, provider: "none", sources: ["memory"] }),
agentId: "main",
});
expect(result.manager).toBeNull();
expect(result.error).toMatch(/no such column: path/);
const exclusiveLock = tryAcquireMemoryReindexSwapLock(storePath);
expect(exclusiveLock).toBeDefined();
exclusiveLock?.release();
});
it("full-reindexes sessions-only retry state when metadata is mismatched", async () => {
const storePath = path.join(workspaceDir, "index-full-session-identity-retry.sqlite");
const memoryManager = await openManager(

View File

@@ -387,49 +387,44 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
this.sources = new Set(effectiveSettings.sources);
this.db = this.openDatabase();
try {
this.providerKey = this.computeProviderKey();
this.cache = {
enabled: effectiveSettings.cache.enabled,
maxEntries: effectiveSettings.cache.maxEntries,
};
this.fts = { enabled: effectiveSettings.query.hybrid.enabled, available: false };
this.ensureSchema();
this.vector = {
enabled: effectiveSettings.store.vector.enabled,
available: null,
extensionPath: effectiveSettings.store.vector.extensionPath,
};
const meta = this.readMeta();
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
const initialIndexIdentity = this.resolveCurrentIndexIdentityState({
meta,
providerKeyKnown: Boolean(params.providerResult),
});
this.indexIdentityState = initialIndexIdentity;
this.indexIdentityDirty =
initialIndexIdentity.status === "mismatched" ||
(initialIndexIdentity.status === "missing" && this.sources.has("memory"));
const transient = params.purpose === "status" || params.purpose === "cli";
if (!transient) {
this.ensureWatcher();
this.ensureSessionListener();
this.ensureIntervalSync();
}
this.dirty = resolveInitialMemoryDirty({
hasMemorySource: this.sources.has("memory"),
statusOnly: params.purpose === "status",
hasIndexedMeta: Boolean(meta),
});
this.batch = this.resolveBatchConfig();
if (!transient) {
this.ensureSessionStartupCatchup();
}
} catch (err) {
closeMemoryDatabase(this.db);
throw err;
this.providerKey = this.computeProviderKey();
this.cache = {
enabled: effectiveSettings.cache.enabled,
maxEntries: effectiveSettings.cache.maxEntries,
};
this.fts = { enabled: effectiveSettings.query.hybrid.enabled, available: false };
this.ensureSchema();
this.vector = {
enabled: effectiveSettings.store.vector.enabled,
available: null,
extensionPath: effectiveSettings.store.vector.extensionPath,
};
const meta = this.readMeta();
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
const initialIndexIdentity = this.resolveCurrentIndexIdentityState({
meta,
providerKeyKnown: Boolean(params.providerResult),
});
this.indexIdentityState = initialIndexIdentity;
this.indexIdentityDirty =
initialIndexIdentity.status === "mismatched" ||
(initialIndexIdentity.status === "missing" && this.sources.has("memory"));
const transient = params.purpose === "status" || params.purpose === "cli";
if (!transient) {
this.ensureWatcher();
this.ensureSessionListener();
this.ensureIntervalSync();
}
this.dirty = resolveInitialMemoryDirty({
hasMemorySource: this.sources.has("memory"),
statusOnly: params.purpose === "status",
hasIndexedMeta: Boolean(meta),
});
this.batch = this.resolveBatchConfig();
if (!transient) {
this.ensureSessionStartupCatchup();
}
}

View File

@@ -1,11 +1,6 @@
// Migrate Hermes plugin module implements apply behavior.
import fs from "node:fs/promises";
import path from "node:path";
import {
markMigrationItemError,
markMigrationItemSkipped,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import { markMigrationItemSkipped, summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
@@ -18,7 +13,6 @@ import type {
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { applyAuthItem } from "./auth.js";
import { applyConfigItem, applyManualItem } from "./config.js";
import { appendItem } from "./helpers.js";
@@ -28,60 +22,6 @@ import { applySecretItem } from "./secrets.js";
import { resolveTargets } from "./targets.js";
const HERMES_REASON_BLOCKED_BY_APPLY_CONFLICT = "blocked by earlier apply conflict";
const HERMES_STATE_DB_ARCHIVE_ITEM_ID = "archive:state.db";
const HERMES_STATE_DB_SNAPSHOT_PREFIX = "openclaw-migrate-hermes-state-";
async function archiveHermesItem(item: MigrationItem, reportDir: string): Promise<MigrationItem> {
if (item.id !== HERMES_STATE_DB_ARCHIVE_ITEM_ID || !item.source) {
return await archiveMigrationItem(item, reportDir);
}
const sourcePath = item.source;
let sourceStat: import("node:fs").Stats;
try {
sourceStat = await fs.lstat(sourcePath);
} catch {
return await archiveMigrationItem(item, reportDir);
}
if (!sourceStat.isFile()) {
return await archiveMigrationItem(item, reportDir);
}
try {
// A raw state.db copy can omit committed rows that still live in state.db-wal.
// Snapshot the live database into one self-contained archive artifact.
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: HERMES_STATE_DB_SNAPSHOT_PREFIX },
async ({ dir: tempDir }) => {
const snapshotPath = path.join(tempDir, "state.db");
const { DatabaseSync } = await import("node:sqlite");
const source = new DatabaseSync(sourcePath, { readOnly: true });
try {
source.exec("PRAGMA busy_timeout = 30000;");
source.prepare("VACUUM INTO ?").run(snapshotPath);
} finally {
source.close();
}
await fs.chmod(snapshotPath, 0o600);
const archived = await archiveMigrationItem({ ...item, source: snapshotPath }, reportDir);
return { ...archived, source: sourcePath };
},
);
} catch (err) {
const snapshotReason = err instanceof Error ? err.message : String(err);
const rawArchive = await archiveMigrationItem(item, reportDir);
if (rawArchive.status === "migrated") {
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed; raw state.db preserved for manual review: ${snapshotReason}`,
);
}
return markMigrationItemError(
rawArchive,
`SQLite snapshot failed: ${snapshotReason}; raw archive failed: ${rawArchive.reason ?? rawArchive.status}`,
);
}
}
export async function applyHermesPlan(params: {
ctx: MigrationProviderContext;
@@ -115,7 +55,7 @@ export async function applyHermesPlan(params: {
} else if (item.kind === "manual") {
appliedItem = applyManualItem(item);
} else if (item.action === "archive") {
appliedItem = await archiveHermesItem(item, reportDir);
appliedItem = await archiveMigrationItem(item, reportDir);
} else if (item.kind === "auth") {
appliedItem = await applyAuthItem(applyCtx, item, targets);
} else if (item.kind === "secret") {

View File

@@ -1,7 +1,6 @@
// Migrate Hermes tests cover files and skills plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { loadAuthProfileStoreWithoutExternalProfiles } from "openclaw/plugin-sdk/agent-runtime";
import { MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
@@ -204,75 +203,6 @@ describe("Hermes migration file and skill items", () => {
await expectPathMissing(path.join(workspaceDir, "logs", "session.log"));
});
it("archives committed Hermes SQLite WAL state", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const reportDir = path.join(root, "report");
const stateDbPath = path.join(source, "state.db");
await fs.mkdir(source, { recursive: true });
const sourceDb = new DatabaseSync(stateDbPath);
try {
sourceDb.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE marker(value TEXT NOT NULL);
PRAGMA wal_checkpoint(TRUNCATE);
`);
sourceDb.prepare("INSERT INTO marker(value) VALUES (?)").run("committed-only-in-wal");
expect((await fs.stat(`${stateDbPath}-wal`)).size).toBeGreaterThan(0);
const provider = buildHermesMigrationProvider();
const result = await provider.apply(
makeContext({ source, stateDir, workspaceDir, reportDir }),
);
const archivedState = itemById(result.items, "archive:state.db");
const archivedStatePath = path.join(reportDir, "archive", "state.db");
expect(archivedState?.status).toBe("migrated");
expect(archivedState?.source).toBe(stateDbPath);
expect(archivedState?.target).toBe(archivedStatePath);
const archivedDb = new DatabaseSync(archivedStatePath, { readOnly: true });
try {
expect(archivedDb.prepare("SELECT value FROM marker").all()).toEqual([
{ value: "committed-only-in-wal" },
]);
expect(archivedDb.prepare("PRAGMA integrity_check").get()).toEqual({
integrity_check: "ok",
});
} finally {
archivedDb.close();
}
} finally {
sourceDb.close();
}
});
it("preserves raw Hermes state when SQLite snapshotting fails", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const reportDir = path.join(root, "report");
const stateDbPath = path.join(source, "state.db");
const archivedStatePath = path.join(reportDir, "archive", "state.db");
await writeFile(stateDbPath, "legacy non-SQLite Hermes state\n");
const provider = buildHermesMigrationProvider();
const result = await provider.apply(makeContext({ source, stateDir, workspaceDir, reportDir }));
const archivedState = itemById(result.items, "archive:state.db");
expect(archivedState?.status).toBe("error");
expect(archivedState?.target).toBe(archivedStatePath);
expect(archivedState?.reason).toContain(
"SQLite snapshot failed; raw state.db preserved for manual review",
);
expect(await fs.readFile(archivedStatePath, "utf8")).toBe("legacy non-SQLite Hermes state\n");
expect(result.summary.errors).toBe(1);
});
it("reports legacy Hermes OpenAI auth.json OAuth state as manual reauth work", async () => {
const root = await makeTempRoot();
const source = path.join(root, "hermes");

View File

@@ -514,82 +514,6 @@ describe("policy commands", () => {
expect(parsed.rulesChecked).toBeGreaterThan(10);
});
it("accepts exec approval allowlist conformance entries with argPattern", async () => {
const policy = {
execApprovals: {
agents: {
allowAutoAllowSkills: false,
allowlist: {
expected: ["status", { pattern: "calendar-cli", argPattern: "^sync\\b" }],
},
},
},
};
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify(policy),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "policy.jsonc"), JSON.stringify(policy), "utf-8");
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed).toMatchObject({
ok: true,
findings: [],
});
});
it("rejects unsupported exec approval allowlist requirement keys in policy compare", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
execApprovals: {
agents: {
allowlist: {
expected: [{ pattern: "deploy", argpattern: "^--prod$" }],
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
execApprovals: {
agents: {
allowlist: {
expected: [{ pattern: "deploy", argPattern: "^--prod$" }],
},
},
},
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(1);
expect(parsed).toMatchObject({
ok: false,
rulesChecked: 0,
});
expect(parsed.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-conformance-invalid",
target: "oc://baseline.policy.jsonc/execApprovals/agents/allowlist/expected/#0",
}),
]),
);
});
it("reports missing and weaker policy file conformance rules", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
@@ -1016,44 +940,6 @@ describe("policy commands", () => {
]);
});
it("accepts stricter later scoped candidate overlays during policy compare", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),
JSON.stringify({
scopes: {
release: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
team: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox", "node"] } },
},
lockdown: {
agentIds: ["main"],
tools: { exec: { allowHosts: ["sandbox"] } },
},
},
}),
"utf-8",
);
const { exitCode, parsed } = await runPolicyCompareJson({
baseline: "baseline.policy.jsonc",
});
expect(exitCode).toBe(0);
expect(parsed.findings).toEqual([]);
});
it("rejects duplicate scoped candidates when any matching scoped value is weaker", async () => {
await fs.writeFile(
join(workspaceDir, "baseline.policy.jsonc"),

View File

@@ -28,8 +28,6 @@ import {
} from "./register.js";
let workspaceDir: string;
let originalOpenClawHome: string | undefined;
let originalOpenClawStateDir: string | undefined;
function cfgWithPolicy(settings: Record<string, unknown> = {}): OpenClawConfig {
return {
@@ -106,37 +104,10 @@ describe("registerPolicyDoctorChecks", () => {
beforeEach(async () => {
clearHealthChecksForTest();
resetPolicyDoctorChecksForTest();
originalOpenClawHome = process.env.OPENCLAW_HOME;
originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
workspaceDir = await fs.mkdtemp(join(tmpdir(), "policy-doctor-"));
process.env.OPENCLAW_HOME = workspaceDir;
delete process.env.OPENCLAW_STATE_DIR;
await fs.mkdir(join(workspaceDir, ".openclaw"), { recursive: true });
try {
await fs.symlink(
"../exec-approvals.json",
join(workspaceDir, ".openclaw", "exec-approvals.json"),
);
} catch (err) {
if (typeof err !== "object" || err === null || !("code" in err) || err.code !== "EPERM") {
throw err;
}
await fs.rm(join(workspaceDir, ".openclaw"), { recursive: true, force: true });
await fs.symlink(workspaceDir, join(workspaceDir, ".openclaw"), "junction");
}
});
afterEach(async () => {
if (originalOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = originalOpenClawHome;
}
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
await fs.rm(workspaceDir, { recursive: true, force: true });
clearHealthChecksForTest();
resetPolicyDoctorChecksForTest();
@@ -278,23 +249,6 @@ describe("registerPolicyDoctorChecks", () => {
strictness: "requires-true",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowSecurity",
strictness: "allowlist-subset",
emptyList: "disabled",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowAutoAllowSkills",
strictness: "requires-false",
selectors: ["agentIds"],
},
{
path: "execApprovals.agents.allowlist.expected",
strictness: "exact-list",
emptyList: "meaningful",
selectors: ["agentIds"],
},
]);
});
@@ -610,13 +564,6 @@ describe("registerPolicyDoctorChecks", () => {
"policy/secrets-insecure-provider",
"policy/auth-profile-invalid-metadata",
"policy/auth-profile-unapproved-mode",
"policy/exec-approvals-missing",
"policy/exec-approvals-invalid",
"policy/exec-approvals-default-security-unapproved",
"policy/exec-approvals-agent-security-unapproved",
"policy/exec-approvals-auto-allow-skills-enabled",
"policy/exec-approvals-allowlist-missing",
"policy/exec-approvals-allowlist-unexpected",
"policy/tools-missing-risk-level",
"policy/tools-unknown-risk-level",
"policy/tools-missing-sensitivity-token",
@@ -7858,768 +7805,6 @@ describe("registerPolicyDoctorChecks", () => {
]);
});
it("reports exec approvals file conformance findings", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
execApprovals: {
requireFile: true,
defaults: { allowSecurity: ["deny"] },
agents: {
allowSecurity: ["allowlist"],
allowlist: { expected: ["deploy", "doctor"] },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
socket: { path: "/tmp/openclaw.sock", token: "secret-token" },
defaults: { security: "full" },
agents: {
sebby: {
security: "full",
allowlist: [{ pattern: "deploy", commandText: "deploy --prod" }],
},
buddy: {
security: "allowlist",
allowlist: [{ pattern: "status" }],
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/exec-approvals-default-security-unapproved",
ocPath: "oc://exec-approvals.json/defaults",
requirement: "oc://policy.jsonc/execApprovals/defaults/allowSecurity",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: "oc://exec-approvals.json/agents/sebby",
requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-missing",
target: "oc://exec-approvals.json",
requirement: "oc://policy.jsonc/execApprovals/agents/allowlist/expected",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-unexpected",
ocPath: "oc://exec-approvals.json/agents/buddy/allowlist/#0",
requirement: "oc://policy.jsonc/execApprovals/agents/allowlist/expected",
}),
]),
);
expect(JSON.stringify(result.findings)).not.toContain("secret-token");
expect(JSON.stringify(result.findings)).not.toContain("deploy --prod");
});
it("compares exec approval allowlist entries with argPattern", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
execApprovals: {
agents: {
allowlist: { expected: [{ pattern: "deploy", argPattern: "^--prod$" }] },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
agents: { main: { allowlist: [{ pattern: "deploy" }] } },
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-missing",
message:
"exec approvals allowlist is missing expected pattern 'deploy argPattern=^--prod$'.",
target: "oc://exec-approvals.json",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-unexpected",
message: "exec approvals allowlist has unexpected pattern 'deploy'.",
ocPath: "oc://exec-approvals.json/agents/main/allowlist/#0",
}),
]);
});
it("checks inherited default security for global exec approval agent rules", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { agents: { allowSecurity: ["allowlist"] } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { security: "full" } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: "oc://exec-approvals.json/defaults",
requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity",
}),
]);
});
it("reports inherited autoAllowSkills when policy requires manual exec allowlists", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { agents: { allowAutoAllowSkills: false } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { autoAllowSkills: true } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-auto-allow-skills-enabled",
ocPath: "oc://exec-approvals.json/defaults",
requirement: "oc://policy.jsonc/execApprovals/agents/allowAutoAllowSkills",
}),
]);
});
it("uses wildcard security for global exec approval agents that only add allowlist entries", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { agents: { allowSecurity: ["deny"] } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "full" },
agents: {
"*": { security: "deny" },
main: { allowlist: [{ pattern: "status" }] },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([]);
});
it("checks default-inherited global exec approval agents when explicit agents exist", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { agents: { allowSecurity: ["allowlist"] } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "full" },
agents: { main: { security: "allowlist" } },
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: "oc://exec-approvals.json/defaults",
requirement: "oc://policy.jsonc/execApprovals/agents/allowSecurity",
}),
]);
});
it("applies scoped exec approvals only to selected agents", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
execApprovals: {
agents: {
allowSecurity: ["allowlist"],
allowlist: { expected: ["deploy", "doctor"] },
},
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "deny" },
agents: {
sebby: {
security: "full",
allowlist: [{ pattern: "deploy" }, { pattern: "status" }],
},
buddy: {
security: "full",
allowlist: [{ pattern: "unrelated" }],
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: "oc://exec-approvals.json/agents/sebby",
requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-missing",
requirement:
"oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-unexpected",
ocPath: "oc://exec-approvals.json/agents/sebby/allowlist/#1",
requirement:
"oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected",
}),
]),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ ocPath: expect.stringContaining("agents/buddy") }),
]),
);
});
it("does not inherit wildcard security when exact agent security is malformed", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
execApprovals: { agents: { allowSecurity: ["deny"] } },
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "deny" },
agents: {
"*": { security: "full" },
sebby: { security: "bogus" },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([]);
});
it("uses runtime defaults for malformed exec approval mode fields", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["full"] } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { security: "bogus" } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([]);
});
it("requires exec approvals artifacts for scoped exec approval rules", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby", "buddy"],
execApprovals: {
agents: { allowSecurity: ["allowlist"] },
},
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-missing",
target: "oc://exec-approvals.json",
requirement: "oc://policy.jsonc/scopes/restricted/execApprovals",
}),
]);
});
it("rejects invalid exec approvals artifacts for scoped exec approval rules", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby", "buddy"],
execApprovals: {
agents: { allowSecurity: ["allowlist"] },
},
},
},
}),
"utf-8",
);
await fs.writeFile(join(workspaceDir, "exec-approvals.json"), "{", "utf-8");
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-invalid",
target: "oc://exec-approvals.json",
requirement: "oc://policy.jsonc/scopes/restricted/execApprovals",
}),
]);
});
it("does not require exec approvals artifacts for requireFile false alone", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { requireFile: false } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([]);
});
it("applies wildcard exec approvals to scoped agents", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
execApprovals: {
agents: {
allowSecurity: ["allowlist"],
allowlist: { expected: ["deploy"] },
},
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "deny" },
agents: {
"*": {
security: "full",
allowlist: [{ pattern: "status" }],
},
sebby: {
allowlist: [{ pattern: "deploy" }],
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: 'oc://exec-approvals.json/agents/"*"',
requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity",
}),
expect.objectContaining({
checkId: "policy/exec-approvals-allowlist-unexpected",
ocPath: 'oc://exec-approvals.json/agents/"*"/allowlist/#0',
requirement:
"oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowlist/expected",
}),
]),
);
});
it("applies wildcard autoAllowSkills posture to scoped exec approvals", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
execApprovals: {
agents: { allowAutoAllowSkills: false },
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
agents: {
"*": { autoAllowSkills: true },
buddy: { autoAllowSkills: true },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-auto-allow-skills-enabled",
ocPath: 'oc://exec-approvals.json/agents/"*"',
requirement:
"oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowAutoAllowSkills",
}),
]);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ ocPath: expect.stringContaining("agents/buddy") }),
]),
);
});
it("applies inherited default autoAllowSkills posture to scoped exec approvals", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["sebby"],
execApprovals: {
agents: { allowAutoAllowSkills: false },
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { autoAllowSkills: true },
agents: {
sebby: { allowlist: [{ pattern: "deploy" }] },
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-auto-allow-skills-enabled",
ocPath: "oc://exec-approvals.json/defaults",
requirement:
"oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowAutoAllowSkills",
}),
]);
});
it("evaluates legacy default exec approvals for scoped main policies", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
scopes: {
restricted: {
agentIds: ["main"],
execApprovals: {
agents: {
allowSecurity: ["deny"],
allowlist: { expected: ["legacy", "doctor"] },
},
},
},
},
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({
version: 1,
defaults: { security: "deny" },
agents: {
default: {
security: "allowlist",
allowlist: ["legacy", { pattern: "doctor" }],
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-agent-security-unapproved",
ocPath: "oc://exec-approvals.json/agents/default",
target: "oc://exec-approvals.json/agents/default",
requirement: "oc://policy.jsonc/scopes/restricted/execApprovals/agents/allowSecurity",
}),
]);
});
it("uses OPENCLAW_HOME for the default exec approvals artifact path", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const openclawHome = join(workspaceDir, "home");
const approvalsDir = join(openclawHome, ".openclaw");
const previousOpenClawHome = process.env.OPENCLAW_HOME;
await fs.mkdir(approvalsDir, { recursive: true });
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["deny"] } } }),
"utf-8",
);
await fs.writeFile(
join(approvalsDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { security: "full" } }),
"utf-8",
);
process.env.OPENCLAW_HOME = openclawHome;
try {
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-default-security-unapproved",
ocPath: "oc://exec-approvals.json/defaults",
}),
]);
} finally {
if (previousOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previousOpenClawHome;
}
}
});
it("uses OPENCLAW_STATE_DIR for the exec approvals artifact path", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
const stateDir = join(workspaceDir, "state");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { defaults: { allowSecurity: ["deny"] } } }),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { security: "deny" } }),
"utf-8",
);
await fs.writeFile(
join(stateDir, "exec-approvals.json"),
JSON.stringify({ version: 1, defaults: { security: "full" } }),
"utf-8",
);
process.env.OPENCLAW_STATE_DIR = stateDir;
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-default-security-unapproved",
ocPath: "oc://exec-approvals.json/defaults",
}),
]);
});
it("rejects unsupported exec approval allowlist requirement keys", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
execApprovals: {
agents: {
allowlist: {
expected: [{ pattern: "deploy", argpattern: "^--prod$" }],
},
},
},
}),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/policy-jsonc-invalid",
target: "oc://policy.jsonc/execApprovals/agents/allowlist/expected/#0",
}),
]),
);
});
it("targets the missing exec approvals artifact when required", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({ execApprovals: { requireFile: true } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-missing",
target: "oc://exec-approvals.json",
requirement: "oc://policy.jsonc/execApprovals/requireFile",
}),
]);
});
it("rejects required versionless exec approvals artifacts", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
execApprovals: { requireFile: true, defaults: { allowSecurity: ["deny"] } },
}),
"utf-8",
);
await fs.writeFile(
join(workspaceDir, "exec-approvals.json"),
JSON.stringify({ defaults: { security: "deny" } }),
"utf-8",
);
registerPolicyDoctorChecks();
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
expect(result.findings).toEqual([
expect.objectContaining({
checkId: "policy/exec-approvals-invalid",
requirement: "oc://policy.jsonc/execApprovals",
}),
]);
});
it("reports malformed secrets policy values before applying secrets checks", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");

File diff suppressed because it is too large Load Diff

View File

@@ -355,49 +355,19 @@ function policyRuleValueIsValid(metadata: PolicyRuleMetadata, value: unknown): b
case "string":
return typeof value === "string" && policyStringIsAllowed(metadata, value);
case "string-list":
if (!Array.isArray(value)) {
return false;
}
if (isExecApprovalAllowlistExpectedRule(metadata)) {
return value.every(isExecApprovalAllowlistRequirement);
}
return value.every(
(entry) =>
typeof entry === "string" &&
entry.trim() !== "" &&
policyStringIsAllowed(metadata, entry),
return (
Array.isArray(value) &&
value.every(
(entry) =>
typeof entry === "string" &&
entry.trim() !== "" &&
policyStringIsAllowed(metadata, entry),
)
);
}
return false;
}
function isExecApprovalAllowlistExpectedRule(metadata: PolicyRuleMetadata): boolean {
return metadata.policyPath.join(".") === "execApprovals.agents.allowlist.expected";
}
function unsupportedPolicyKey(
value: Record<string, unknown>,
supported: readonly string[],
): string | undefined {
return Object.keys(value).find((key) => !supported.includes(key));
}
function isExecApprovalAllowlistRequirement(value: unknown): boolean {
if (typeof value === "string") {
return value.trim() !== "";
}
if (!isRecord(value)) {
return false;
}
if (unsupportedPolicyKey(value, ["argPattern", "pattern"]) !== undefined) {
return false;
}
if (typeof value.pattern !== "string" || value.pattern.trim() === "") {
return false;
}
return value.argPattern === undefined || typeof value.argPattern === "string";
}
function policyStringIsAllowed(metadata: PolicyRuleMetadata, value: string): boolean {
const normalized = metadata.caseSensitive === true ? value.trim() : value.trim().toLowerCase();
if (normalized === "") {
@@ -536,25 +506,7 @@ function collectScopedPolicyRuleClaims(document: PolicyDocument): readonly Polic
}
}
}
return coalesceScopedPolicyRuleClaims(claims);
}
function coalesceScopedPolicyRuleClaims(
claims: readonly PolicyRuleClaim[],
): readonly PolicyRuleClaim[] {
const byKey = new Map<string, PolicyRuleClaim>();
for (const claim of claims) {
const previous = byKey.get(claim.key);
if (
previous !== undefined &&
isPolicyValueAtLeastAsStrict(previous.metadata, claim.value, previous.value)
) {
byKey.set(claim.key, claim);
continue;
}
byKey.set(claim.key, previous ?? claim);
}
return [...byKey.values()];
return claims;
}
function normalizeSelectorValues(

View File

@@ -1,6 +1,6 @@
// Policy tests cover policy state plugin behavior.
import { describe, expect, it } from "vitest";
import { scanPolicyChannels, scanPolicyExecApprovals, scanPolicyTools } from "./policy-state.js";
import { scanPolicyChannels, scanPolicyTools } from "./policy-state.js";
describe("scanPolicyChannels", () => {
it("ignores reserved channel config namespaces", () => {
@@ -84,123 +84,3 @@ describe("scanPolicyTools", () => {
]);
});
});
describe("scanPolicyExecApprovals", () => {
it("scans redacted exec approvals posture and allowlist metadata", () => {
const evidence = scanPolicyExecApprovals(
JSON.stringify({
version: 1,
socket: { path: "/tmp/openclaw.sock", token: "secret-token" },
defaults: { security: "full", ask: "off", askFallback: "full", autoAllowSkills: true },
agents: {
sebby: {
security: "allowlist",
ask: "on-miss",
allowlist: [
{
pattern: "deploy",
argPattern: "^--prod$",
source: "allow-always",
commandText: "deploy --prod",
lastUsedCommand: "deploy --prod",
},
{
pattern: "inspect",
source: "free-form text that must not leak",
},
],
},
},
}),
);
expect(evidence).toEqual([
expect.objectContaining({
id: "defaults",
kind: "defaults",
security: "full",
autoAllowSkills: true,
}),
expect.objectContaining({
id: "agent:sebby",
kind: "agent",
agentId: "sebby",
security: "allowlist",
ask: "on-miss",
}),
expect.objectContaining({
id: "agent:sebby:allowlist:0",
kind: "allowlist",
agentId: "sebby",
pattern: "deploy",
argPattern: "^--prod$",
entrySource: "allow-always",
}),
expect.not.objectContaining({
entrySource: "free-form text that must not leak",
}),
]);
expect(JSON.stringify(evidence)).not.toContain("secret-token");
expect(JSON.stringify(evidence)).not.toContain("deploy --prod");
expect(JSON.stringify(evidence)).not.toContain("free-form text that must not leak");
});
it("omits malformed exec approval mode fields", () => {
expect(
scanPolicyExecApprovals(
JSON.stringify({
version: 1,
defaults: { security: "bogus", ask: "bad", askFallback: "nope" },
agents: {
sebby: { security: "bogus", ask: "bad", askFallback: "nope" },
},
}),
),
).toEqual([
expect.not.objectContaining({ security: expect.any(String) }),
expect.not.objectContaining({ security: expect.any(String) }),
]);
});
it("normalizes legacy default agents and string allowlist entries", () => {
expect(
scanPolicyExecApprovals(
JSON.stringify({
version: 1,
agents: {
default: {
security: "allowlist",
allowlist: ["legacy", { pattern: "doctor" }],
},
},
}),
),
).toEqual([
expect.objectContaining({
id: "defaults",
kind: "defaults",
}),
expect.objectContaining({
id: "agent:main",
kind: "agent",
agentId: "main",
security: "allowlist",
source: "oc://exec-approvals.json/agents/default",
}),
expect.objectContaining({
id: "agent:main:allowlist:0",
kind: "allowlist",
agentId: "main",
pattern: "legacy",
source: "oc://exec-approvals.json/agents/default/allowlist/#0",
}),
expect.objectContaining({
id: "agent:main:allowlist:1",
kind: "allowlist",
agentId: "main",
pattern: "doctor",
source: "oc://exec-approvals.json/agents/default/allowlist/#1",
}),
]);
});
});

View File

@@ -12,7 +12,6 @@ import { POLICY_TOOL_GROUPS } from "./tool-policy-conformance.js";
// Mirrors the sandbox browser config default without importing core internals into the policy plugin.
const DEFAULT_POLICY_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser";
const DEFAULT_EXEC_APPROVAL_AGENT_ID = "main";
const ALLOWLIST_DEFAULT_INGRESS_GROUP_POLICY_CHANNELS = new Set([
"googlechat",
"irc",
@@ -54,7 +53,6 @@ export type PolicyEvidence = {
readonly dataHandling?: readonly PolicyDataHandlingEvidence[];
readonly secrets?: readonly PolicySecretEvidence[];
readonly authProfiles?: readonly PolicyAuthProfileEvidence[];
readonly execApprovals?: readonly PolicyExecApprovalEvidence[];
};
export type PolicyChannelEvidence = {
@@ -211,21 +209,6 @@ export type PolicyAuthProfileEvidence = {
readonly mode?: string;
};
export type PolicyExecApprovalEvidence = {
readonly id: string;
readonly kind: "agent" | "allowlist" | "defaults";
readonly source: string;
readonly agentId?: string;
readonly security?: string;
readonly securityConfigured?: boolean;
readonly ask?: string;
readonly askFallback?: string;
readonly autoAllowSkills?: boolean;
readonly pattern?: string;
readonly argPattern?: string;
readonly entrySource?: string;
};
export type PolicyDataHandlingEvidence = {
readonly id: string;
readonly kind:
@@ -319,8 +302,6 @@ export function collectPolicyEvidence(
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
readonly execApprovalsRaw?: string | null;
readonly includeExecApprovals?: boolean;
},
): PolicyEvidence;
export function collectPolicyEvidence(
@@ -335,8 +316,6 @@ export function collectPolicyEvidence(
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
readonly execApprovalsRaw?: string | null;
readonly includeExecApprovals?: boolean;
},
): Promise<PolicyEvidence>;
export function collectPolicyEvidence(
@@ -351,8 +330,6 @@ export function collectPolicyEvidence(
readonly includeSandboxPosture?: boolean;
readonly includeSecrets?: boolean;
readonly includeAuthProfiles?: boolean;
readonly execApprovalsRaw?: string | null;
readonly includeExecApprovals?: boolean;
} = {},
): PolicyEvidence | Promise<PolicyEvidence> {
const evidence = {
@@ -375,14 +352,6 @@ export function collectPolicyEvidence(
: { sandboxPosture: scanPolicySandboxPosture(cfg) }),
...(options.includeSecrets === false ? {} : { secrets: scanPolicySecrets(cfg) }),
...(options.includeAuthProfiles === false ? {} : { authProfiles: scanPolicyAuthProfiles(cfg) }),
...(options.includeExecApprovals === false || options.execApprovalsRaw === undefined
? {}
: {
execApprovals:
options.execApprovalsRaw === null
? []
: scanPolicyExecApprovals(options.execApprovalsRaw),
}),
};
if (options.toolsRaw === undefined) {
return evidence;
@@ -390,278 +359,6 @@ export function collectPolicyEvidence(
return scanPolicyTools(options.toolsRaw).then((tools) => ({ ...evidence, tools }));
}
export function scanPolicyExecApprovals(raw: string): readonly PolicyExecApprovalEvidence[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed) || parsed.version !== 1) {
return [];
}
const evidence: PolicyExecApprovalEvidence[] = [];
const defaults = isRecord(parsed.defaults) ? parsed.defaults : {};
evidence.push(
execApprovalPostureEvidence(
"defaults",
"defaults",
defaults,
"oc://exec-approvals.json/defaults",
),
);
for (const agent of normalizedExecApprovalAgents(parsed.agents)) {
const agentSource = `oc://exec-approvals.json/agents/${ocPathSegment(agent.sourceAgentId)}`;
evidence.push(
execApprovalPostureEvidence(
`agent:${agent.agentId}`,
"agent",
agent.value,
agentSource,
agent.agentId,
),
);
for (const [index, entry] of agent.allowlistEntries.entries()) {
const allowlistSource = `oc://exec-approvals.json/agents/${ocPathSegment(
entry.sourceAgentId,
)}/allowlist/#${entry.index}`;
evidence.push({
id: `agent:${agent.agentId}:allowlist:${index}`,
kind: "allowlist",
source: allowlistSource,
agentId: agent.agentId,
pattern: entry.pattern,
...(entry.argPattern === undefined ? {} : { argPattern: entry.argPattern }),
...(entry.entrySource === undefined ? {} : { entrySource: entry.entrySource }),
});
}
}
return evidence;
}
function execApprovalPostureEvidence(
id: string,
kind: "agent" | "defaults",
value: Record<string, unknown>,
source: string,
agentId?: string,
): PolicyExecApprovalEvidence {
const security = readExecApprovalSecurity(value.security);
const ask = readExecApprovalAsk(value.ask);
const askFallback = readExecApprovalSecurity(value.askFallback);
const autoAllowSkills = readBoolean(value.autoAllowSkills);
return {
id,
kind,
source,
...(agentId === undefined ? {} : { agentId }),
...(value.security == null ? {} : { securityConfigured: true }),
...(security === undefined ? {} : { security }),
...(ask === undefined ? {} : { ask }),
...(askFallback === undefined ? {} : { askFallback }),
...(autoAllowSkills === undefined ? {} : { autoAllowSkills }),
};
}
function readExecApprovalSecurity(value: unknown): string | undefined {
const normalized = readString(value);
return normalized === "deny" || normalized === "allowlist" || normalized === "full"
? normalized
: undefined;
}
function readExecApprovalAsk(value: unknown): string | undefined {
const normalized = readString(value);
return normalized === "off" || normalized === "on-miss" || normalized === "always"
? normalized
: undefined;
}
type NormalizedExecApprovalAllowlistEntry = ReturnType<
typeof execApprovalAllowlistEntries
>[number] & {
readonly sourceAgentId: string;
};
type NormalizedExecApprovalAgent = {
readonly agentId: string;
readonly sourceAgentId: string;
readonly value: Record<string, unknown>;
readonly allowlistEntries: readonly NormalizedExecApprovalAllowlistEntry[];
};
function normalizedExecApprovalAgents(rawAgents: unknown): readonly NormalizedExecApprovalAgent[] {
if (!isRecord(rawAgents)) {
return [];
}
const agents = Object.entries(rawAgents).filter(
(entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]),
);
const legacyDefault = agents.find(([agentId]) => agentId === "default")?.[1];
const normalized = agents
.filter(([agentId]) => agentId !== "default")
.map(([agentId, value]): NormalizedExecApprovalAgent => {
if (agentId === DEFAULT_EXEC_APPROVAL_AGENT_ID && legacyDefault !== undefined) {
return {
agentId,
sourceAgentId: agentId,
value: mergeLegacyExecApprovalAgent(value, legacyDefault),
allowlistEntries: mergedExecApprovalAllowlistEntries(
value.allowlist,
legacyDefault.allowlist,
),
};
}
return execApprovalAgentFromParts(agentId, agentId, value);
});
if (
legacyDefault !== undefined &&
!agents.some(([agentId]) => agentId === DEFAULT_EXEC_APPROVAL_AGENT_ID)
) {
normalized.push(
execApprovalAgentFromParts(DEFAULT_EXEC_APPROVAL_AGENT_ID, "default", legacyDefault),
);
}
return normalized.toSorted((a, b) => a.agentId.localeCompare(b.agentId));
}
function execApprovalAgentFromParts(
agentId: string,
sourceAgentId: string,
value: Record<string, unknown>,
): NormalizedExecApprovalAgent {
const allowlistEntries = execApprovalAllowlistEntries(value.allowlist).map(
(entry): NormalizedExecApprovalAllowlistEntry => ({
index: entry.index,
pattern: entry.pattern,
argPattern: entry.argPattern,
entrySource: entry.entrySource,
sourceAgentId,
}),
);
return {
agentId,
sourceAgentId,
value,
allowlistEntries,
};
}
function mergeLegacyExecApprovalAgent(
current: Record<string, unknown>,
legacy: Record<string, unknown>,
): Record<string, unknown> {
return {
...legacy,
...current,
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: mergedExecApprovalAllowlist(current.allowlist, legacy.allowlist),
};
}
function mergedExecApprovalAllowlist(
current: unknown,
legacy: unknown,
): readonly unknown[] | undefined {
const entries = mergedExecApprovalAllowlistEntries(current, legacy).map((entry) => {
const allowlistEntry: Record<string, unknown> = { pattern: entry.pattern };
if (entry.argPattern !== undefined) {
allowlistEntry.argPattern = entry.argPattern;
}
if (entry.entrySource !== undefined) {
allowlistEntry.source = entry.entrySource;
}
return allowlistEntry;
});
return entries.length === 0 ? undefined : entries;
}
function mergedExecApprovalAllowlistEntries(
current: unknown,
legacy: unknown,
): readonly NormalizedExecApprovalAllowlistEntry[] {
const entries: NormalizedExecApprovalAllowlistEntry[] = [];
const seen = new Set<string>();
const appendEntries = (sourceEntries: readonly NormalizedExecApprovalAllowlistEntry[]) => {
for (const sourceEntry of sourceEntries) {
const key = `${sourceEntry.pattern.toLowerCase()}\x00${sourceEntry.argPattern ?? ""}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
entries.push(sourceEntry);
}
};
appendEntries(withExecApprovalAllowlistSource(current, DEFAULT_EXEC_APPROVAL_AGENT_ID));
appendEntries(withExecApprovalAllowlistSource(legacy, "default"));
return entries;
}
function withExecApprovalAllowlistSource(
value: unknown,
sourceAgentId: string,
): readonly NormalizedExecApprovalAllowlistEntry[] {
return execApprovalAllowlistEntries(value).map(
(entry): NormalizedExecApprovalAllowlistEntry => ({
index: entry.index,
pattern: entry.pattern,
argPattern: entry.argPattern,
entrySource: entry.entrySource,
sourceAgentId,
}),
);
}
function readExecApprovalAllowlistEntrySource(value: unknown): "allow-always" | undefined {
return readString(value) === "allow-always" ? "allow-always" : undefined;
}
function execApprovalAllowlistEntries(value: unknown): readonly {
readonly index: number;
readonly pattern: string;
readonly argPattern?: string;
readonly entrySource?: string;
}[] {
if (!Array.isArray(value)) {
return [];
}
const entries: {
readonly index: number;
readonly pattern: string;
readonly argPattern?: string;
readonly entrySource?: string;
}[] = [];
for (const [index, entry] of value.entries()) {
if (typeof entry === "string") {
const pattern = entry.trim();
if (pattern !== "") {
entries.push({ index, pattern });
}
continue;
}
if (!isRecord(entry)) {
continue;
}
const pattern = readString(entry.pattern);
if (pattern === undefined) {
continue;
}
const argPattern = readString(entry.argPattern);
const entrySource = readExecApprovalAllowlistEntrySource(entry.source);
entries.push({
index,
pattern,
...(argPattern === undefined ? {} : { argPattern }),
...(entrySource === undefined ? {} : { entrySource }),
});
}
return entries;
}
export function scanPolicyChannels(cfg: Record<string, unknown>): readonly PolicyChannelEvidence[] {
return Object.entries(configuredChannels(cfg))
.filter(([id]) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id))

View File

@@ -81,7 +81,6 @@ export {
} from "./src/scenario-catalog.js";
export { createQaSelfCheckScenario } from "./src/self-check-scenario.js";
export {
isQaSelfCheckSuccessful,
type QaSelfCheckResult,
resolveQaSelfCheckOutputPath,
runQaSelfCheckAgainstState,

View File

@@ -16,17 +16,12 @@
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/slack": "workspace:*",
"@openclaw/whatsapp": "workspace:*",
"crabline": "github:openclaw/crabline#5e8031a660f9f8c40746c79830c7caf780080ee7",
"openclaw": "2026.5.28"
},
"peerDependencies": {
"crabline": "*",
"openclaw": ">=2026.6.2"
},
"peerDependenciesMeta": {
"crabline": {
"optional": true
},
"openclaw": {
"optional": true
}

View File

@@ -284,13 +284,6 @@ describe("qa cli runtime", () => {
baseUrl: "http://127.0.0.1:58000",
runSelfCheck: vi.fn().mockResolvedValue({
outputPath: "/tmp/report.md",
report: "",
checks: [{ name: "QA self-check scenario", status: "pass" }],
scenarioResult: {
name: "QA self-check scenario",
status: "pass",
steps: [],
},
}),
stop: vi.fn(),
});
@@ -445,12 +438,6 @@ describe("qa cli runtime", () => {
fastMode: true,
concurrency: 2,
});
expect(suiteArgs.channelDriverSelection).toEqual({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
});
expect(suiteArgs.scenarioIds).toEqual(expect.arrayContaining(["dm-chat-baseline"]));
expect(suiteArgs.scenarioIds).not.toContain("thinking-slash-model-remap");
expect(process.env.OPENCLAW_QA_PROFILE).toBe("release");
@@ -498,19 +485,6 @@ describe("qa cli runtime", () => {
}
});
it("keeps non-Crabline profile channel drivers out of SDK driver selection", async () => {
await runQaProfileCommand({
repoRoot: "/tmp/openclaw-repo",
profile: "release",
surface: "agent-runtime-and-provider-execution",
category: "agent-runtime-and-provider-execution.agent-turn-execution",
providerMode: "mock-openai",
});
const suiteArgs = mockFirstObjectArg(runQaSuite);
expect(suiteArgs.channelDriverSelection).toBeUndefined();
});
it("rejects qa profile runs that do not match taxonomy categories", async () => {
await expect(
runQaProfileCommand({
@@ -559,48 +533,6 @@ describe("qa cli runtime", () => {
});
});
it("passes Crabline channel-driver selection into host suite runs", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
outputDir: ".artifacts/qa/multipass-telegram",
providerMode: "mock-openai",
channelDriver: "crabline",
channel: "telegram",
scenarioIds: ["channel-chat-baseline"],
});
expect(runQaSuite).toHaveBeenCalledWith({
repoRoot: path.resolve("/tmp/openclaw-repo"),
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa/multipass-telegram"),
transportId: "qa-channel",
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
fastMode: undefined,
scenarioIds: ["channel-chat-baseline"],
});
});
it("keeps Crabline channel-driver independent from the VM runner", async () => {
await expect(
runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
providerMode: "mock-openai",
channelDriver: "crabline",
channel: "telegram",
runner: "multipass",
}),
).rejects.toThrow("--channel-driver crabline requires --runner host.");
expect(runQaSuite).not.toHaveBeenCalled();
expect(runQaMultipass).not.toHaveBeenCalled();
});
it("passes explicit suite plugin enablements into the host gateway run", async () => {
await runQaSuiteCommand({
repoRoot: "/tmp/openclaw-repo",
@@ -2222,33 +2154,6 @@ describe("qa cli runtime", () => {
});
});
it("fails unsuccessful self-checks after stopping the lab server", async () => {
const stop = vi.fn();
startQaLabServer.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:58000",
runSelfCheck: vi.fn().mockResolvedValue({
outputPath: "/tmp/failed-report.md",
report: "",
checks: [{ name: "QA self-check scenario", status: "fail" }],
scenarioResult: {
name: "QA self-check scenario",
status: "fail",
steps: [],
},
}),
stop,
});
await expect(
runQaLabSelfCheckCommand({
repoRoot: "/tmp/openclaw-repo",
}),
).rejects.toThrow("QA self-check failed. See /tmp/failed-report.md.");
expect(stop).toHaveBeenCalledOnce();
expectWriteContains(stdoutWrite, "QA self-check report: /tmp/failed-report.md");
});
it("resolves docker scaffold paths relative to the explicit repo root", async () => {
await runQaDockerScaffoldCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -26,7 +26,6 @@ import {
renderQaCoverageMarkdownReport,
renderQaScenarioMatchesMarkdownReport,
} from "./coverage-report.js";
import { resolveQaCrablineChannelDriverSelection } from "./crabline-channel-driver.js";
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
import { runQaDockerUp } from "./docker-up.runtime.js";
import { QaSuiteArtifactError, QaSuiteInfraError } from "./errors.js";
@@ -74,9 +73,7 @@ import {
readQaScorecardTaxonomyReport,
type QaScorecardCategoryCoverageReport,
type QaScorecardEvidenceMode,
type QaScorecardProfileReport,
} from "./scorecard-taxonomy.js";
import { isQaSelfCheckSuccessful } from "./self-check.js";
import { runQaFlowSuiteFromRuntime, runQaSuite } from "./suite-launch.runtime.js";
import { scenarioMatchesQaProviderLane } from "./suite-planning.js";
import { readQaSuiteFailedOrSkippedScenarioCountFromFile } from "./suite-summary.js";
@@ -133,8 +130,6 @@ export type QaProfileCommandOptions = QaScenarioRunCommandOptions & {
};
export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
channelDriver?: string;
channel?: string;
runner?: string;
thinking?: string;
cliAuthMode?: string;
@@ -622,9 +617,6 @@ export async function runQaLabSelfCheckCommand(opts: QaLabSelfCheckCommandOption
try {
const result = await server.runSelfCheck();
process.stdout.write(`QA self-check report: ${result.outputPath}\n`);
if (!isQaSelfCheckSuccessful(result)) {
throw new Error(`QA self-check failed. See ${result.outputPath}.`);
}
} finally {
await server.stop();
}
@@ -638,10 +630,6 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
opts.profile,
scorecardReport.profiles.map((entry) => entry.id),
);
const profileReport = scorecardReport.profiles.find((entry) => entry.id === profile);
if (!profileReport) {
throw new Error(`taxonomy.yaml does not define QA run profile ${profile}.`);
}
const categories = scorecardReport.categories.filter((category) =>
qaScorecardCategoryMatchesRunProfile(category, {
profile,
@@ -692,7 +680,6 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
scenarioIds: scenarios.map((scenario) => scenario.id),
concurrency: opts.concurrency,
allowFailures: opts.allowFailures,
...qaSuiteChannelDriverOptionsForProfile(profileReport),
});
evidencePath =
suiteResult && "evidencePath" in suiteResult ? suiteResult.evidencePath : undefined;
@@ -713,18 +700,6 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
process.stdout.write(`QA profile scorecard: ${evidencePath}\n`);
}
function qaSuiteChannelDriverOptionsForProfile(
profile: QaScorecardProfileReport,
): Pick<QaSuiteCommandOptions, "channelDriver" | "channel"> {
if (profile.channelDriver !== "crabline") {
return {};
}
return {
channelDriver: profile.channelDriver,
channel: profile.channel ?? undefined,
};
}
function normalizeQaRunProfile(value: string, profileIds: readonly string[]) {
if (profileIds.length === 0) {
throw new Error("taxonomy.yaml does not define QA run profiles.");
@@ -787,10 +762,6 @@ async function withTemporaryQaProfileEnv<T>(profile: string, run: () => Promise<
export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const transportId = normalizeQaTransportId(opts.transportId);
const channelDriverSelection = resolveQaCrablineChannelDriverSelection({
channel: opts.channel,
channelDriver: opts.channelDriver,
});
const runner = (opts.runner ?? "host").trim().toLowerCase();
const explicitScenarioIds = resolveQaScenarioPackScenarioIds({
pack: opts.pack,
@@ -816,9 +787,6 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
if (opts.preflight === true && runner !== "host") {
throw new Error("--preflight requires --runner host.");
}
if (channelDriverSelection && runner !== "host") {
throw new Error("--channel-driver crabline requires --runner host.");
}
if (
runner === "host" &&
(opts.image !== undefined ||
@@ -887,7 +855,6 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
outputDir: resolveRepoRelativeOutputDir(repoRoot, opts.outputDir),
evidenceMode: opts.evidenceMode,
transportId,
...(channelDriverSelection ? { channelDriverSelection } : {}),
...(opts.providerMode !== undefined ? { providerMode } : {}),
primaryModel,
alternateModel,

View File

@@ -62,8 +62,6 @@ const QA_RUN_PROFILE_ONLY_OPTIONS = [
const QA_RUN_SELF_CHECK_ONLY_OPTIONS = [{ optionName: "output", flag: "--output" }] as const;
type QaSuiteCliOptions = QaScenarioRunCliOptions & {
channelDriver?: QaSuiteCommandOptions["channelDriver"];
channel?: QaSuiteCommandOptions["channel"];
runner?: QaSuiteCommandOptions["runner"];
thinking?: QaSuiteCommandOptions["thinking"];
cliAuthMode?: QaSuiteCommandOptions["cliAuthMode"];
@@ -455,11 +453,6 @@ export function registerQaLabCli(program: Command) {
.option("--output-dir <path>", "Suite artifact directory")
.option("--runner <kind>", "Execution runner: host or multipass", "host")
.option("--transport <id>", "QA transport id", "qa-channel")
.option("--channel-driver <id>", "Internal host QA channel SDK driver id; currently crabline")
.option(
"--channel <id>",
"Internal host QA channel id for --channel-driver; currently telegram",
)
.option("--provider-mode <mode>", formatQaProviderModeHelp())
.option("--model <ref>", "Primary provider/model ref")
.option("--alt-model <ref>", "Alternate provider/model ref")
@@ -511,8 +504,6 @@ export function registerQaLabCli(program: Command) {
repoRoot: opts.repoRoot,
outputDir: opts.outputDir,
transportId: opts.transport,
channelDriver: opts.channelDriver,
channel: opts.channel,
runner: opts.runner,
providerMode: opts.providerMode,
primaryModel: opts.model,

View File

@@ -18,7 +18,6 @@ const TEST_WEBCHAT_COVERAGE_ID = "ui.webchat";
function testMaturityTaxonomy(params?: {
categoryId?: string;
coverageIds?: readonly string[];
includeAllCategories?: boolean;
profileCategoryIds?: readonly string[];
}) {
const categoryId = params?.categoryId ?? TEST_EXECUTABLE_CATEGORY_ID;
@@ -32,18 +31,12 @@ function testMaturityTaxonomy(params?: {
{
id: "smoke-ci",
description: "Test smoke profile.",
includeAllCategories: false,
channelDriver: "qa-channel" as const,
categoryIds: [],
},
{
id: "release",
description: "Test release profile.",
includeAllCategories: params?.includeAllCategories ?? false,
channelDriver: "qa-channel" as const,
categoryIds: [
...(params?.includeAllCategories ? [] : (params?.profileCategoryIds ?? [categoryId])),
],
categoryIds: [...(params?.profileCategoryIds ?? [categoryId])],
},
],
surfaces: [
@@ -121,24 +114,8 @@ describe("qa coverage report", () => {
"whatsapp",
]);
expect(inventory.scorecardTaxonomy.profileCount).toBe(2);
expect(
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "smoke-ci"),
).toMatchObject({
channel: "telegram",
channelDriver: "crabline",
evidenceMode: "slim",
});
expect(
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "release"),
).toMatchObject({
channel: null,
channelDriver: "live",
});
expect(inventory.scorecardTaxonomy.categoryCount).toBeGreaterThan(200);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeLessThanOrEqual(
inventory.scorecardTaxonomy.categoryCount,
);
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBe(15);
expect(inventory.scorecardTaxonomy.requiredFeatureCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.fulfilledFeatureCount).toBeGreaterThan(0);
expect(inventory.scorecardTaxonomy.taxonomyFulfillmentPercent).toBeGreaterThan(0);
@@ -147,15 +124,30 @@ describe("qa coverage report", () => {
expect(inventory.scorecardTaxonomy.unknownCoverageIdCount).toBe(0);
expect(inventory.scorecardTaxonomy.validationIssues.length).toBeGreaterThan(0);
expect(
inventory.scorecardTaxonomy.validationIssues.some((issue) =>
issue.code.endsWith("not-found"),
),
).toBe(false);
expect(
inventory.scorecardTaxonomy.validationIssues.some(
inventory.scorecardTaxonomy.validationIssues.every(
(issue) => issue.code === "coverage-id-missing-primary-evidence",
),
).toBe(true);
expect(
inventory.scorecardTaxonomy.profiles
.find((profile) => profile.id === "release")
?.categoryIds.toSorted(),
).toEqual([
"agent-runtime-and-provider-execution.agent-turn-execution",
"automation-cron-hooks-tasks-polling.cron-jobs",
"browser-automation-and-exec-sandbox-tools.tool-invocation-and-execution",
"browser-control-ui-and-webchat.browser-ui",
"media-understanding-and-media-generation.media-generation",
"media-understanding-and-media-generation.media-understanding",
"openai-codex-provider-path.responses-and-tool-compatibility",
"plugin-sdk-and-bundled-plugin-architecture.installing-and-running-plugins",
"security-auth-pairing-and-secrets.approval-policy-and-tool-safeguards",
"security-auth-pairing-and-secrets.credential-and-secret-hygiene",
"session-memory-and-context-engine.diagnostics-maintenance-and-recovery",
"session-memory-and-context-engine.memory",
"session-memory-and-context-engine.token-management",
"telemetry-diagnostics-and-observability.telemetry-export",
]);
expect(
inventory.scorecardTaxonomy.categories.find(
(category) => category.id === TEST_BROWSER_CATEGORY_ID,
@@ -357,21 +349,6 @@ describe("qa coverage report", () => {
);
});
it("resolves all-category profiles from taxonomy categories", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy({
includeAllCategories: true,
}),
repoRoot: process.cwd(),
scenarios: [],
});
expect(report.profiles.find((profile) => profile.id === "release")?.categoryIds).toStrictEqual([
TEST_EXECUTABLE_CATEGORY_ID,
]);
expect(report.requiredCategoryCount).toBe(1);
});
it("reports profile categories missing primary coverage evidence", () => {
const report = buildQaScorecardTaxonomyReport({
taxonomy: testMaturityTaxonomy(),

View File

@@ -1,64 +0,0 @@
// Qa Lab tests cover Crabline channel-driver metadata behavior.
import { describe, expect, it } from "vitest";
import {
runQaCrablineChannelDriverSmoke,
resolveQaCrablineChannelDriverSelection,
} from "./crabline-channel-driver.js";
describe("crabline channel driver metadata", () => {
it("returns null when no channel driver is selected", () => {
expect(resolveQaCrablineChannelDriverSelection({})).toBeNull();
});
it("resolves the Telegram SDK-backed channel driver", () => {
const selection = resolveQaCrablineChannelDriverSelection({
channel: "telegram",
channelDriver: "crabline",
});
expect(selection).toEqual({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
});
});
it("runs Crabline's imported deterministic local driver smoke", async () => {
await expect(
runQaCrablineChannelDriverSmoke({
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
}),
).resolves.toMatchObject({
driver: {
channel: "telegram",
driverId: "telegram-local-v1",
},
result: {
ok: true,
providerId: "telegram-local",
},
});
});
it("requires a supported channel when the driver is selected", () => {
expect(() => resolveQaCrablineChannelDriverSelection({ channelDriver: "crabline" })).toThrow(
"--channel is required",
);
expect(() =>
resolveQaCrablineChannelDriverSelection({
channel: "slack",
channelDriver: "crabline",
}),
).toThrow("--channel must be one of telegram");
});
it("rejects channel identity without a channel driver", () => {
expect(() => resolveQaCrablineChannelDriverSelection({ channel: "telegram" })).toThrow(
"--channel requires --channel-driver crabline",
);
});
});

View File

@@ -1,100 +0,0 @@
// Qa Lab plugin module models SDK-backed Crabline channel-driver metadata.
import {
findLocalChannelDriver,
listLocalChannelDriverMatrix,
runLocalChannelDriverSmoke,
type LocalChannelDriverSmokeResult,
} from "crabline";
type CrablineChannel = Parameters<typeof runLocalChannelDriverSmoke>[0]["channel"];
export type QaChannelDriverId = "crabline";
export type QaCrablineChannelId = CrablineChannel;
export type QaCrablineChannelDriverSelection = {
channel: QaCrablineChannelId;
channelDriver: QaChannelDriverId;
capabilityMatrixPath: typeof QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH;
smokeArtifactPath: typeof QA_CRABLINE_CHANNEL_SMOKE_PATH;
};
export const QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH = "crabline-channel-capability-matrix.json";
export const QA_CRABLINE_CHANNEL_SMOKE_PATH = "crabline-channel-smoke.json";
function listSupportedCrablineChannels(): QaCrablineChannelId[] {
return Array.from(
new Set(listLocalChannelDriverMatrix().drivers.map((driver) => driver.channel)),
) as QaCrablineChannelId[];
}
export function normalizeQaChannelDriverId(input?: string | null): QaChannelDriverId | null {
const normalized = input?.trim().toLowerCase();
if (!normalized) {
return null;
}
if (normalized === "crabline") {
return "crabline";
}
throw new Error(`--channel-driver must be crabline, got "${input}".`);
}
export function normalizeQaCrablineChannel(input?: string | null): QaCrablineChannelId {
const normalized = input?.trim().toLowerCase();
if (!normalized) {
throw new Error("--channel is required when --channel-driver crabline is set.");
}
const channel = normalized as QaCrablineChannelId;
if (findLocalChannelDriver({ channel })) {
return channel;
}
const supportedChannels = listSupportedCrablineChannels();
throw new Error(
`--channel must be one of ${supportedChannels.join(", ")} for --channel-driver crabline, got "${input}".`,
);
}
export function resolveQaCrablineChannelDriverSelection(params: {
channel?: string | null;
channelDriver?: string | null;
}): QaCrablineChannelDriverSelection | null {
const channelDriver = normalizeQaChannelDriverId(params.channelDriver);
if (!channelDriver) {
if (params.channel?.trim()) {
throw new Error("--channel requires --channel-driver crabline.");
}
return null;
}
const channel = normalizeQaCrablineChannel(params.channel);
return {
channel,
channelDriver,
capabilityMatrixPath: QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH,
smokeArtifactPath: QA_CRABLINE_CHANNEL_SMOKE_PATH,
};
}
export async function runQaCrablineChannelDriverSmoke(
selection: QaCrablineChannelDriverSelection,
): Promise<LocalChannelDriverSmokeResult> {
return await runLocalChannelDriverSmoke({
channel: selection.channel,
manifestPath: "openclaw-qa-lab-crabline-smoke.json",
userName: "openclaw-qa",
});
}
export function createQaCrablineChannelReportNotes(
selection: QaCrablineChannelDriverSelection | null | undefined,
): string[] {
if (!selection) {
return [];
}
return [
`Channel driver: ${selection.channelDriver} for ${selection.channel}.`,
`Channel capability matrix: ${selection.capabilityMatrixPath}.`,
`Channel driver smoke: ${selection.smokeArtifactPath}.`,
"This is the openclaw/crabline messaging SDK driver path; it is independent of the Canonical Multipass VM runner.",
];
}

View File

@@ -255,67 +255,6 @@ describe("WhatsApp QA live runtime", () => {
expect(report).not.toContain("+15550000002");
});
it("publishes WhatsApp gateway debug artifacts only when files exist", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-debug-test-"));
const debugDir = path.join(tempRoot, "gateway-debug");
try {
await expect(testing.hasWhatsAppGatewayDebugArtifacts(debugDir)).resolves.toBe(false);
await fs.mkdir(debugDir);
await expect(testing.hasWhatsAppGatewayDebugArtifacts(debugDir)).resolves.toBe(false);
await fs.writeFile(path.join(debugDir, "gateway.stderr.log"), "stderr\n");
await expect(testing.hasWhatsAppGatewayDebugArtifacts(debugDir)).resolves.toBe(true);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("redacts published WhatsApp run output without advertising empty debug artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-publish-test-"));
const debugDir = path.join(tempRoot, "gateway-debug");
try {
await fs.mkdir(debugDir);
const emptyDebugView = await testing.buildPublishedWhatsAppQaRunView({
cleanupIssues: [
"WhatsApp QA failed before scenario completion: private setup failure details",
],
gatewayDebugDirPath: debugDir,
preservedGatewayDebugArtifacts: true,
redactMetadata: true,
scenarioResults: [
{
id: "whatsapp-canary",
title: "WhatsApp DM canary",
standardId: "canary",
status: "fail",
details: "private setup failure details",
},
],
});
expect(emptyDebugView.gatewayDebugDirPath).toBeUndefined();
expect(emptyDebugView.cleanupIssues).toEqual([
"WhatsApp QA failed before scenario completion: " +
"details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)",
]);
expect(emptyDebugView.scenarioResults[0]?.details).toBe(
"details redacted (OPENCLAW_QA_REDACT_PUBLIC_METADATA=1)",
);
await fs.writeFile(path.join(debugDir, "gateway.stderr.log"), "stderr\n");
await expect(
testing.buildPublishedWhatsAppQaRunView({
cleanupIssues: [],
gatewayDebugDirPath: debugDir,
preservedGatewayDebugArtifacts: true,
redactMetadata: true,
scenarioResults: [],
}),
).resolves.toMatchObject({ gatewayDebugDirPath: debugDir });
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("redacts published scenario details before rendering public artifacts", () => {
const publishedScenarios = testing.redactWhatsAppQaScenarioResults([
{
@@ -753,94 +692,6 @@ describe("WhatsApp QA live runtime", () => {
expect(diagnostics).not.toContain("unrelated text");
});
it("adds safe diagnostics when a WhatsApp scenario reply wait observes nothing", async () => {
const driver = createWhatsAppQaDriverMock({
getObservedMessages: () => [],
waitForMessage: async () => {
throw new Error("timed out waiting for WhatsApp QA driver message");
},
});
const recorded: unknown[] = [];
const context = {
driver,
driverPhoneE164: "+15550000001",
gateway: {
call: async () => ({}),
restart: async () => {},
workspaceDir: "/tmp/openclaw-whatsapp-qa-gateway",
},
gatewayTarget: "+15550000001",
gatewayWorkspaceDir: "/tmp/openclaw-whatsapp-qa-gateway",
recordObservedMessage: (message: unknown) => {
recorded.push(message);
},
requestStartedAt: new Date("2026-06-05T01:00:00.000Z"),
scenarioId: "whatsapp-canary",
scenarioTitle: "WhatsApp DM canary",
sent: { messageId: "driver-message-1" },
sutAccountId: "sut",
sutPhoneE164: "+15550000002",
target: "+15550000002",
waitForReady: async () => {},
} satisfies Parameters<typeof testing.waitForScenarioObservedMessage>[0];
await expect(
testing.waitForScenarioObservedMessage(context, {
observedAfter: new Date("2026-06-05T01:00:00.000Z"),
match: () => true,
}),
).rejects.toThrow("observed 0 WhatsApp driver message(s) after wait lower bound");
expect(recorded).toEqual([]);
});
it("lets WhatsApp scenario waits use caller-specific sender matching", async () => {
const groupReply = {
fromJid: "120363000000000000@g.us",
fromPhoneE164: null,
kind: "text" as const,
messageId: "group-reply-1",
observedAt: "2026-06-05T01:00:01.000Z",
text: "group token",
};
const driver = createWhatsAppQaDriverMock({
waitForMessage: async (params) => {
expect(params.match(groupReply)).toBe(true);
return groupReply;
},
});
const recorded: unknown[] = [];
const context = {
driver,
driverPhoneE164: "+15550000001",
gateway: {
call: async () => ({}),
restart: async () => {},
workspaceDir: "/tmp/openclaw-whatsapp-qa-gateway",
},
gatewayTarget: "120363000000000000@g.us",
gatewayWorkspaceDir: "/tmp/openclaw-whatsapp-qa-gateway",
recordObservedMessage: (message: unknown) => {
recorded.push(message);
},
requestStartedAt: new Date("2026-06-05T01:00:00.000Z"),
scenarioId: "whatsapp-mention-gating",
scenarioTitle: "WhatsApp group mention gating",
sent: { messageId: "driver-message-1" },
sutAccountId: "sut",
sutPhoneE164: "+15550000002",
target: "120363000000000000@g.us",
waitForReady: async () => {},
} satisfies Parameters<typeof testing.waitForScenarioObservedMessage>[0];
await expect(
testing.waitForScenarioObservedMessage(context, {
expectedSender: (message) => message.fromJid === "120363000000000000@g.us",
match: (message) => message.text.includes("group token"),
}),
).resolves.toBe(groupReply);
expect(recorded).toEqual([groupReply]);
});
it("formats per-scenario progress lines for live lane visibility", () => {
const [scenario] = testing.findScenarios(["whatsapp-inbound-structured-messages"]);
if (!scenario) {
@@ -881,13 +732,6 @@ describe("WhatsApp QA live runtime", () => {
"textLength=17 messageId=present(length=10) quoted=missing " +
"quotedMessageId=missing fromExpectedSut=yes",
);
expect(
testing.formatWhatsAppScenarioProgressDetails({
details:
"timed out waiting for WhatsApp QA driver message; observed 0 WhatsApp driver message(s) after wait lower bound",
redactMetadata: true,
}),
).toBe("observed 0 WhatsApp driver message(s) after wait lower bound");
expect(
testing.formatWhatsAppScenarioProgressDetails({
details: "safe local diagnostic",

View File

@@ -31,6 +31,7 @@ import {
} from "../shared/credential-lease.runtime.js";
import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
redactQaLiveLaneDetails,
redactQaLiveLaneIssues,
} from "../shared/live-artifacts.js";
@@ -1894,7 +1895,6 @@ async function waitForScenarioObservedMessage(
label: string;
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
}>;
expectedSender?: (message: WhatsAppQaDriverObservedMessage) => boolean;
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
observedAfter?: Date;
timeoutMs?: number;
@@ -1906,8 +1906,7 @@ async function waitForScenarioObservedMessage(
observedAfter: params.observedAfter,
timeoutMs: params.timeoutMs ?? 45_000,
match: (candidate) =>
(params.expectedSender?.(candidate) ?? candidate.fromPhoneE164 === context.sutPhoneE164) &&
params.match(candidate),
candidate.fromPhoneE164 === context.sutPhoneE164 && params.match(candidate),
});
} catch (error) {
if (/\btimed out waiting for WhatsApp QA driver message\b/iu.test(formatErrorMessage(error))) {
@@ -2523,8 +2522,6 @@ async function runWhatsAppScenario(params: {
sutAuthDir: string;
sutPhoneE164: string;
groupJid?: string;
onGatewayDebugPreserveFailure?: (error: unknown) => void;
onGatewayDebugPreserved?: () => void;
}): Promise<WhatsAppQaScenarioResult> {
const scenarioRun = params.scenario.buildRun();
if (scenarioRun.kind !== "approval" && scenarioRun.target === "group" && !params.groupJid) {
@@ -2712,19 +2709,26 @@ async function runWhatsAppScenario(params: {
details: "no reply",
};
}
const reply = await waitForScenarioObservedMessage(scenarioContext, {
const reply = await params.driver.waitForMessage({
observedAfter: requestStartedAt,
timeoutMs: params.scenario.timeoutMs,
expectedSender: (message) =>
scenarioRun.target === "group"
match: (message) =>
(scenarioRun.target === "group"
? message.fromJid === params.groupJid
: message.fromPhoneE164 === params.sutPhoneE164,
match: (message) => messageMatches(message as WhatsAppObservedMessage, scenarioRun.matchText),
: message.fromPhoneE164 === params.sutPhoneE164) &&
messageMatches(message as WhatsAppObservedMessage, scenarioRun.matchText),
});
const observed: WhatsAppObservedMessage = {
...reply,
matchedScenario: true,
scenarioId: params.scenario.id,
scenarioTitle: params.scenario.title,
};
scenarioRun.verify?.(reply, scenarioContext);
params.observedMessages.push(observed);
const afterReplyDetails = await scenarioRun.afterReply?.(reply, scenarioContext);
const batchDetails = await assertWhatsAppScenarioMessageBatch({
alreadyRecordedMessageIds: new Set(reply.messageId ? [reply.messageId] : []),
alreadyRecordedMessageIds: new Set(observed.messageId ? [observed.messageId] : []),
context: scenarioContext,
observedAfter: requestStartedAt,
run: scenarioRun,
@@ -2750,13 +2754,8 @@ async function runWhatsAppScenario(params: {
},
};
} catch (error) {
try {
await gatewayHarness.stop({ preserveToDir: params.gatewayDebugDirPath });
preservedGatewayDebug = true;
params.onGatewayDebugPreserved?.();
} catch (preserveError) {
params.onGatewayDebugPreserveFailure?.(preserveError);
}
preservedGatewayDebug = true;
await gatewayHarness.stop({ preserveToDir: params.gatewayDebugDirPath }).catch(() => {});
throw error;
} finally {
if (!preservedGatewayDebug) {
@@ -2949,43 +2948,6 @@ function appendPreScenarioFailureResults(params: {
}
}
async function hasWhatsAppGatewayDebugArtifacts(gatewayDebugDirPath: string) {
try {
const entries = await fs.readdir(gatewayDebugDirPath);
return entries.length > 0;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function buildPublishedWhatsAppQaRunView(params: {
cleanupIssues: string[];
gatewayDebugDirPath: string;
preservedGatewayDebugArtifacts: boolean;
redactMetadata: boolean;
scenarioResults: WhatsAppQaScenarioResult[];
}) {
const publishedCleanupIssues = params.redactMetadata
? redactQaLiveLaneIssues(params.cleanupIssues)
: params.cleanupIssues;
const publishedScenarioResults = params.redactMetadata
? redactWhatsAppQaScenarioResults(params.scenarioResults)
: params.scenarioResults;
const gatewayDebugDirPath =
params.preservedGatewayDebugArtifacts &&
(await hasWhatsAppGatewayDebugArtifacts(params.gatewayDebugDirPath))
? params.gatewayDebugDirPath
: undefined;
return {
cleanupIssues: publishedCleanupIssues,
gatewayDebugDirPath,
scenarioResults: publishedScenarioResults,
};
}
function formatWhatsAppScenarioProgressLine(params: {
details?: string;
index: number;
@@ -3112,8 +3074,6 @@ export async function runWhatsAppQaLive(params: {
}
let driverAttempt = 1;
while (true) {
let scenarioGatewayDebugPreserved = false;
const scenarioGatewayDebugPreserveFailures: unknown[] = [];
try {
const result = await runWhatsAppScenario({
driver: activeDriver,
@@ -3130,12 +3090,6 @@ export async function runWhatsAppQaLive(params: {
sutAccountId,
sutAuthDir,
sutPhoneE164: runtimeEnv.sutPhoneE164,
onGatewayDebugPreserved: () => {
scenarioGatewayDebugPreserved = true;
},
onGatewayDebugPreserveFailure: (error) => {
scenarioGatewayDebugPreserveFailures.push(error);
},
});
const recordedResult =
driverAttempt > 1
@@ -3172,12 +3126,7 @@ export async function runWhatsAppQaLive(params: {
closeDriverSession = () => activeDriver.close();
continue;
}
if (scenarioGatewayDebugPreserved) {
preservedGatewayDebugArtifacts = true;
}
for (const preserveError of scenarioGatewayDebugPreserveFailures) {
appendLiveLaneIssue(cleanupIssues, "gateway debug preserve failed", preserveError);
}
preservedGatewayDebugArtifacts = true;
const result: WhatsAppQaScenarioResult = {
id: scenario.id,
title: scenario.title,
@@ -3207,7 +3156,17 @@ export async function runWhatsAppQaLive(params: {
}
}
} catch (error) {
appendLiveLaneIssue(cleanupIssues, "WhatsApp QA failed before scenario completion", 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,
@@ -3247,20 +3206,19 @@ export async function runWhatsAppQaLive(params: {
const summaryPath = path.join(outputDir, QA_EVIDENCE_FILENAME);
const observedMessagesPath = path.join(outputDir, "whatsapp-qa-observed-messages.json");
const credentialFingerprint = fingerprintQaCredentialId(credentialLease?.credentialId);
const publishedRunView = await buildPublishedWhatsAppQaRunView({
cleanupIssues,
gatewayDebugDirPath,
preservedGatewayDebugArtifacts,
redactMetadata: redactPublicMetadata,
scenarioResults,
});
const publishedCleanupIssues = redactPublicMetadata
? redactQaLiveLaneIssues(cleanupIssues)
: cleanupIssues;
const publishedScenarioResults = redactPublicMetadata
? redactWhatsAppQaScenarioResults(scenarioResults)
: scenarioResults;
const evidence = buildLiveTransportEvidenceSummary({
artifactPaths: [
{ kind: "summary", path: path.basename(summaryPath) },
{ kind: "report", path: path.basename(reportPath) },
{ kind: "transport-observations", path: path.basename(observedMessagesPath) },
],
checks: publishedRunView.scenarioResults.map(({ standardId, ...check }) => ({
checks: publishedScenarioResults.map(({ standardId, ...check }) => ({
...check,
coverageIds: standardId ? [`channels.whatsapp.${standardId}`] : undefined,
})),
@@ -3286,13 +3244,13 @@ export async function runWhatsAppQaLive(params: {
await fs.writeFile(
reportPath,
`${renderWhatsAppQaMarkdown({
cleanupIssues: publishedRunView.cleanupIssues,
cleanupIssues: publishedCleanupIssues,
credentialFingerprint,
credentialSource: credentialLease?.source ?? requestedCredentialSource,
finishedAt,
gatewayDebugDirPath: publishedRunView.gatewayDebugDirPath,
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
redactMetadata: redactPublicMetadata,
scenarios: publishedRunView.scenarioResults,
scenarios: publishedScenarioResults,
startedAt,
sutPhoneE164: runtimeEnv?.sutPhoneE164,
})}\n`,
@@ -3302,7 +3260,7 @@ export async function runWhatsAppQaLive(params: {
reportPath,
summaryPath,
observedMessagesPath,
gatewayDebugDirPath: publishedRunView.gatewayDebugDirPath,
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
scenarios: scenarioResults,
};
}
@@ -3310,7 +3268,6 @@ export async function runWhatsAppQaLive(params: {
export const testing = {
assertSafeArchiveEntries,
appendPreScenarioFailureResults,
buildPublishedWhatsAppQaRunView,
buildWhatsAppQaConfig,
callWhatsAppGatewayMessageAction,
callWhatsAppGatewayPoll,
@@ -3324,13 +3281,11 @@ export const testing = {
formatWhatsAppScenarioProgressLine,
fingerprintWhatsAppCredentialId: fingerprintQaCredentialId,
formatWhatsAppScenarioWaitDiagnostics,
hasWhatsAppGatewayDebugArtifacts,
isTransientWhatsAppQaDriverError,
matchesWhatsAppApprovalResolvedText,
parseWhatsAppQaCredentialPayload,
renderWhatsAppQaMarkdown,
runWhatsAppStructuredInboundChecks,
waitForScenarioObservedMessage,
redactWhatsAppQaScenarioResults,
resolveWhatsAppQaMessageTargets,
resolveWhatsAppQaRuntimeEnv,

View File

@@ -20,15 +20,11 @@ function isRepoRootRelativeRef(value: string) {
const qaCoverageEvidenceRoleSchema = z.enum(["primary", "secondary"]);
export const qaScorecardEvidenceModeSchema = z.enum(["full", "slim"]);
export const qaScorecardChannelDriverSchema = z.enum(["qa-channel", "crabline", "live"]);
const qaScorecardProfileSchema = z.object({
id: qaScorecardIdSchema,
description: z.string().trim().min(1),
evidenceMode: qaScorecardEvidenceModeSchema.optional(),
includeAllCategories: z.boolean().default(false),
channelDriver: qaScorecardChannelDriverSchema.default("qa-channel"),
channel: qaScorecardIdSchema.optional(),
categoryIds: z.array(qaScorecardIdSchema).default([]),
});
@@ -71,42 +67,6 @@ const qaMaturityTaxonomySchema = z
}
seenProfileIds.add(profile.id);
if (profile.includeAllCategories && profile.categoryIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "categoryIds"],
message: `profile ${profile.id} cannot set categoryIds when includeAllCategories is true`,
});
}
if (profile.channelDriver === "crabline" && !profile.channel) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "channel"],
message: `profile ${profile.id} requires channel when channelDriver is crabline`,
});
}
if (profile.channelDriver !== "crabline" && profile.channel) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "channel"],
message: `profile ${profile.id} can only set channel when channelDriver is crabline`,
});
}
if (profile.channelDriver === "crabline" && profile.includeAllCategories) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "includeAllCategories"],
message: `profile ${profile.id} cannot set includeAllCategories when channelDriver is crabline`,
});
}
if (profile.channelDriver === "crabline" && !profile.categoryIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["profiles", profileIndex, "categoryIds"],
message: `profile ${profile.id} requires categoryIds when channelDriver is crabline`,
});
}
const seenProfileCategoryIds = new Set<string>();
for (const [categoryIndex, categoryId] of profile.categoryIds.entries()) {
if (seenProfileCategoryIds.has(categoryId)) {
@@ -124,7 +84,6 @@ const qaMaturityTaxonomySchema = z
export type QaNativeCoverageEvidenceKind = "vitest" | "playwright";
export type QaScorecardEvidenceKind = QaNativeCoverageEvidenceKind | "qa-scenario";
export type QaScorecardEvidenceMode = z.infer<typeof qaScorecardEvidenceModeSchema>;
export type QaScorecardChannelDriver = z.infer<typeof qaScorecardChannelDriverSchema>;
type QaCoverageEvidenceRole = z.infer<typeof qaCoverageEvidenceRoleSchema>;
type QaMaturityTaxonomy = z.infer<typeof qaMaturityTaxonomySchema>;
@@ -170,8 +129,6 @@ export type QaScorecardCategoryCoverageReport = {
export type QaScorecardProfileReport = {
id: string;
evidenceMode: QaScorecardEvidenceMode;
channelDriver: QaScorecardChannelDriver;
channel: string | null;
categoryIds: string[];
};
@@ -382,15 +339,12 @@ export function readQaScorecardFeatureCoverageByCategory(repoRoot?: string) {
export function readQaScorecardProfileOptions(profileId: string | undefined, repoRoot?: string) {
const profile = profileId?.trim();
if (!profile) {
return { evidenceMode: "full" as const, channelDriver: "qa-channel" as const, channel: null };
return { evidenceMode: "full" as const };
}
const profileOptions = readQaMaturityTaxonomy(repoRoot)?.profiles.find(
(entry) => entry.id === profile,
);
return {
evidenceMode: profileOptions?.evidenceMode ?? "full",
channelDriver: profileOptions?.channelDriver ?? "qa-channel",
channel: profileOptions?.channel ?? null,
evidenceMode:
readQaMaturityTaxonomy(repoRoot)?.profiles.find((entry) => entry.id === profile)
?.evidenceMode ?? "full",
};
}
@@ -512,10 +466,7 @@ export function buildQaScorecardTaxonomyReport(params: {
const profiles =
params.taxonomy?.profiles.map((profile) => {
const validCategoryIds: string[] = [];
const selectedCategoryIds = profile.includeAllCategories
? [...maturityRefs.categories.keys()]
: profile.categoryIds;
for (const categoryId of selectedCategoryIds) {
for (const categoryId of profile.categoryIds) {
if (!maturityRefs.categories.has(categoryId)) {
issues.push({
code: "profile-category-ref-not-found",
@@ -533,8 +484,6 @@ export function buildQaScorecardTaxonomyReport(params: {
return {
id: profile.id,
evidenceMode: profile.evidenceMode ?? "full",
channelDriver: profile.channelDriver,
channel: profile.channel ?? null,
categoryIds: validCategoryIds,
};
}) ?? [];

View File

@@ -1,47 +1,7 @@
// Qa Lab tests cover self check plugin behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { QaSelfCheckResult } from "./self-check.js";
import { isQaSelfCheckSuccessful, resolveQaSelfCheckOutputPath } from "./self-check.js";
function makeSelfCheckResult(params: {
scenarioStatus: "pass" | "fail";
checkStatuses: Array<"pass" | "fail">;
}): QaSelfCheckResult {
return {
outputPath: "/tmp/qa-self-check.md",
report: "",
checks: params.checkStatuses.map((status, index) => ({
name: `check ${String(index + 1)}`,
status,
})),
scenarioResult: {
name: "QA self-check scenario",
status: params.scenarioStatus,
steps: [],
},
};
}
describe("isQaSelfCheckSuccessful", () => {
it("requires the scenario and every check to pass", () => {
expect(
isQaSelfCheckSuccessful(
makeSelfCheckResult({ scenarioStatus: "pass", checkStatuses: ["pass"] }),
),
).toBe(true);
expect(
isQaSelfCheckSuccessful(
makeSelfCheckResult({ scenarioStatus: "fail", checkStatuses: ["pass"] }),
),
).toBe(false);
expect(
isQaSelfCheckSuccessful(
makeSelfCheckResult({ scenarioStatus: "pass", checkStatuses: ["pass", "fail"] }),
),
).toBe(false);
});
});
import { resolveQaSelfCheckOutputPath } from "./self-check.js";
describe("resolveQaSelfCheckOutputPath", () => {
it("keeps explicit output paths untouched", () => {

View File

@@ -15,13 +15,6 @@ export type QaSelfCheckResult = {
scenarioResult: QaScenarioResult;
};
export function isQaSelfCheckSuccessful(result: QaSelfCheckResult): boolean {
return (
result.scenarioResult.status === "pass" &&
result.checks.every((check) => check.status === "pass")
);
}
export function resolveQaSelfCheckOutputPath(params?: { outputPath?: string; repoRoot?: string }) {
if (params?.outputPath) {
return params.outputPath;

View File

@@ -1,7 +1,6 @@
// Qa Lab plugin module implements suite summary behavior.
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { QaCrablineChannelDriverSelection } from "./crabline-channel-driver.js";
import { QaSuiteArtifactError } from "./errors.js";
import type { QaEvidenceSummaryJson } from "./evidence-summary.js";
import type { QaProviderMode } from "./model-selection.js";
@@ -56,10 +55,6 @@ export type QaSuiteSummaryJson = {
alternateModelName: string | null;
fastMode: boolean;
concurrency: number;
channelDriver: QaCrablineChannelDriverSelection["channelDriver"] | null;
channel: QaCrablineChannelDriverSelection["channel"] | null;
channelCapabilityMatrixPath: string | null;
channelDriverSmokePath: string | null;
scenarioIds: string[] | null;
runtimePair?: [RuntimeId, RuntimeId] | null;
};

View File

@@ -34,30 +34,9 @@ describe("buildQaSuiteSummaryJson", () => {
expect(json.run.alternateModelName).toBe("gpt-5.5-alt");
expect(json.run.fastMode).toBe(true);
expect(json.run.concurrency).toBe(2);
expect(json.run.channelDriver).toBeNull();
expect(json.run.channel).toBeNull();
expect(json.run.channelCapabilityMatrixPath).toBeNull();
expect(json.run.channelDriverSmokePath).toBeNull();
expect(json.run.scenarioIds).toBeNull();
});
it("records Crabline channel-driver metadata when selected", () => {
const json = buildQaSuiteSummaryJson({
...baseParams,
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
});
expect(json.run.channelDriver).toBe("crabline");
expect(json.run.channel).toBe("telegram");
expect(json.run.channelCapabilityMatrixPath).toBe("crabline-channel-capability-matrix.json");
expect(json.run.channelDriverSmokePath).toBe("crabline-channel-smoke.json");
});
it("includes scenarioIds in run metadata when provided", () => {
const scenarioIds = ["approval-turn-tool-followthrough", "subagent-handoff", "memory-recall"];
const json = buildQaSuiteSummaryJson({

View File

@@ -272,63 +272,6 @@ describe("qa suite", () => {
}
});
it("writes Crabline channel-driver smoke artifacts when selected", async () => {
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-suite-crabline-"));
try {
const artifacts = await qaSuiteProgressTesting.writeQaSuiteArtifacts({
outputDir,
startedAt: new Date("2026-04-11T00:00:00.000Z"),
finishedAt: new Date("2026-04-11T00:01:00.000Z"),
scenarios: [{ name: "Telegram DM", status: "pass", steps: [] }],
scenarioDefinitions: [
{
...makeQaSuiteTestScenario("telegram-dm", {
surface: "channel",
}),
coverage: {
primary: ["channels.dm"],
},
},
],
transport: {
id: "qa-channel",
createReportNotes: () => [],
} as unknown as QaTransportAdapter,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
fastMode: true,
concurrency: 1,
channelDriverSelection: {
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-channel-smoke.json",
},
});
const matrix = JSON.parse(
await fs.readFile(path.join(outputDir, "crabline-channel-capability-matrix.json"), "utf8"),
) as { matrix?: Array<{ capabilityId?: string }> };
expect(matrix.matrix).toEqual(
expect.arrayContaining([expect.objectContaining({ capabilityId: "telegram.dm.text" })]),
);
const smoke = JSON.parse(
await fs.readFile(path.join(outputDir, "crabline-channel-smoke.json"), "utf8"),
) as { result?: { ok?: boolean } };
expect(smoke.result?.ok).toBe(true);
const evidence = JSON.parse(await fs.readFile(artifacts.evidencePath, "utf8")) as {
entries?: Array<{ execution?: { channel?: { driver?: string; id?: string } } }>;
};
expect(evidence.entries?.[0]?.execution?.channel).toMatchObject({
driver: "crabline",
id: "telegram",
});
} finally {
await fs.rm(outputDir, { recursive: true, force: true });
}
});
it("arms gateway heap checkpoint env only when requested", () => {
expect(
qaSuiteProgressTesting.buildQaGatewayHeapCheckpointRuntimeEnvPatch({

View File

@@ -12,11 +12,6 @@ import {
type QaReportScenario,
} from "openclaw/plugin-sdk/qa-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createQaCrablineChannelReportNotes,
runQaCrablineChannelDriverSmoke,
type QaCrablineChannelDriverSelection,
} from "./crabline-channel-driver.js";
import { QaSuiteArtifactError } from "./errors.js";
import { buildQaSuiteEvidenceSummary, QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js";
@@ -112,7 +107,6 @@ export type QaSuiteRunParams = {
outputDir?: string;
providerMode?: QaProviderMode;
transportId?: QaTransportId;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
@@ -424,7 +418,6 @@ function buildRuntimeParityScenarioResult(params: {
function createQaSuiteReportNotes(params: {
transport: QaTransportAdapter;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
providerMode: QaProviderMode;
primaryModel: string;
alternateModel: string;
@@ -432,10 +425,7 @@ function createQaSuiteReportNotes(params: {
concurrency: number;
isolatedWorkers?: boolean;
}) {
return [
...params.transport.createReportNotes(params),
...createQaCrablineChannelReportNotes(params.channelDriverSelection),
];
return params.transport.createReportNotes(params);
}
function buildQaIsolatedScenarioWorkerParams(params: {
@@ -443,7 +433,6 @@ function buildQaIsolatedScenarioWorkerParams(params: {
outputDir: string;
providerMode: QaProviderMode;
transportId: QaTransportId;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
primaryModel: string;
alternateModel: string;
fastMode: boolean;
@@ -456,7 +445,6 @@ function buildQaIsolatedScenarioWorkerParams(params: {
outputDir: params.outputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriverSelection: params.channelDriverSelection,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
@@ -562,7 +550,6 @@ export type QaSuiteSummaryJsonParams = {
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
};
@@ -622,10 +609,6 @@ export function buildQaSuiteSummaryJson(params: QaSuiteSummaryJsonParams): QaSui
alternateModelName: alternateSplit?.model ?? null,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriver: params.channelDriverSelection?.channelDriver ?? null,
channel: params.channelDriverSelection?.channel ?? null,
channelCapabilityMatrixPath: params.channelDriverSelection?.capabilityMatrixPath ?? null,
channelDriverSmokePath: params.channelDriverSelection?.smokeArtifactPath ?? null,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0 ? [...params.scenarioIds] : null,
runtimePair: params.runtimePair ?? null,
@@ -646,7 +629,6 @@ async function runQaRuntimeParitySuite(params: {
thinkingDefault?: QaThinkingLevel;
claudeCliAuthMode?: QaCliBackendAuthMode;
enabledPluginIds?: string[];
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
concurrency: number;
selectedScenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
startLab?: QaSuiteStartLabFn;
@@ -719,7 +701,6 @@ async function runQaRuntimeParitySuite(params: {
outputDir: cellOutputDir,
providerMode: params.providerMode,
transportId: params.transportId,
channelDriverSelection: params.channelDriverSelection,
primaryModel: remapModelRefForForcedRuntime({
modelRef: params.primaryModel,
providerMode: params.providerMode,
@@ -821,7 +802,6 @@ async function runQaRuntimeParitySuite(params: {
alternateModel: params.alternateModel,
fastMode: params.fastMode,
concurrency: params.concurrency,
channelDriverSelection: params.channelDriverSelection,
scenarioIds:
params.scenarioIds && params.scenarioIds.length > 0
? params.selectedScenarios.map((scenario) => scenario.id)
@@ -874,7 +854,6 @@ async function writeQaSuiteArtifacts(params: {
alternateModel: string;
fastMode: boolean;
concurrency: number;
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
isolatedWorkers?: boolean;
scenarioIds?: readonly string[];
runtimePair?: [RuntimeId, RuntimeId];
@@ -882,9 +861,6 @@ async function writeQaSuiteArtifacts(params: {
const reportPath = path.join(params.outputDir, "qa-suite-report.md");
const summaryPath = path.join(params.outputDir, "qa-suite-summary.json");
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
const channelDriverSmoke = params.channelDriverSelection
? await runQaCrablineChannelDriverSmoke(params.channelDriverSelection)
: undefined;
const report = renderQaMarkdownReport({
title: "OpenClaw QA Scenario Suite",
startedAt: params.startedAt,
@@ -904,22 +880,9 @@ async function writeQaSuiteArtifacts(params: {
artifactPaths: [
{ kind: "summary", path: path.basename(summaryPath) },
{ kind: "report", path: path.basename(reportPath) },
...(params.channelDriverSelection
? [
{
kind: "channel-capability-matrix",
path: params.channelDriverSelection.capabilityMatrixPath,
},
{
kind: "channel-driver-smoke",
path: params.channelDriverSelection.smokeArtifactPath,
},
]
: []),
],
evidenceMode: params.evidenceMode,
channelId: params.channelDriverSelection?.channel ?? params.transport.id,
channelDriver: params.channelDriverSelection?.channelDriver,
channelId: params.transport.id,
env: process.env,
generatedAt: params.finishedAt.toISOString(),
primaryModel: params.primaryModel,
@@ -928,29 +891,6 @@ async function writeQaSuiteArtifacts(params: {
scenarioResults: params.scenarios,
})
: undefined;
if (params.channelDriverSelection && channelDriverSmoke) {
await fs.writeFile(
path.join(params.outputDir, params.channelDriverSelection.capabilityMatrixPath),
`${JSON.stringify(
{
version: 1,
source: "openclaw/crabline",
channelDriver: params.channelDriverSelection.channelDriver,
selectedChannel: params.channelDriverSelection.channel,
driver: channelDriverSmoke.driver,
matrix: channelDriverSmoke.matrix,
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(params.outputDir, params.channelDriverSelection.smokeArtifactPath),
`${JSON.stringify(channelDriverSmoke, null, 2)}\n`,
"utf8",
);
}
await fs.writeFile(reportPath, report, "utf8");
if (evidence) {
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
@@ -1177,7 +1117,6 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
startedAt,
providerMode,
transportId,
channelDriverSelection: params?.channelDriverSelection,
primaryModel,
alternateModel,
fastMode,
@@ -1251,7 +1190,6 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
scenarioIds:
params?.scenarioIds && params.scenarioIds.length > 0
@@ -1300,7 +1238,6 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
outputDir: scenarioOutputDir,
providerMode,
transportId,
channelDriverSelection: params?.channelDriverSelection,
primaryModel,
alternateModel,
fastMode,
@@ -1398,7 +1335,6 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: true,
// When the caller supplied an explicit non-empty --scenario filter,
// record the executed (post-selectQaFlowSuiteScenarios-normalized) ids
@@ -1661,7 +1597,6 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
alternateModel,
fastMode,
concurrency,
channelDriverSelection: params?.channelDriverSelection,
isolatedWorkers: false,
// Same "filtered → executed list, unfiltered → null" convention as
// the concurrent-path writeQaSuiteArtifacts call above.

View File

@@ -49,7 +49,6 @@ import {
resolveTelegramOutboundClientTimeoutFloorSeconds,
} from "./client-fetch.js";
import { resolveTelegramTransport } from "./fetch.js";
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js";
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
@@ -291,13 +290,11 @@ export function createTelegramBotCore(
DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupHistories = new Map<string, HistoryEntry[]>();
const telegramTextLimit =
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT;
const textLimit = Math.min(
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
fallbackLimit: telegramTextLimit,
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
}),
telegramTextLimit,
TELEGRAM_RICH_TEXT_LIMIT,
);
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;

View File

@@ -73,50 +73,6 @@ function transcribeCallContext(index = 0): Record<string, unknown> {
}
describe("resolveTelegramInboundBody", () => {
it("delivers rich-message-only updates as a sanitized placeholder", async () => {
const result = await resolveTelegramBody({
msg: {
message_id: 0,
date: 1_700_000_000,
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
rich_message: { blocks: [{ type: "paragraph" }] },
} as never,
});
expect(result?.rawBody).toBe("[unsupported Telegram rich_message received]");
expect(result?.bodyText).toBe("[unsupported Telegram rich_message received]");
});
it("keeps rich-message placeholders quiet in requireMention groups", async () => {
const logger = { info: vi.fn() };
const result = await resolveTelegramBody({
cfg: {
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: ["\\btelegram\\b"] } },
} as never,
msg: {
message_id: 1,
date: 1_700_000_001,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
from: { id: 42, first_name: "Pat" },
rich_message: { blocks: [{ type: "paragraph" }] },
} as never,
isGroup: true,
chatId: -1001234567890,
senderId: "42",
groupConfig: { requireMention: true } as never,
requireMention: true,
logger,
});
expect(logger.info).toHaveBeenCalledWith(
{ chatId: -1001234567890, reason: "no-mention" },
"skipping group message",
);
expect(result).toBeNull();
});
it("renders Telegram text entities before building the agent body", async () => {
const result = await resolveTelegramBody({
msg: {

View File

@@ -41,7 +41,6 @@ import {
hasBotMention,
renderTelegramTextEntities,
resolveTelegramPrimaryMedia,
resolveTelegramRichMessagePlaceholder,
} from "./bot/body-helpers.js";
import { buildTelegramGroupPeerId, buildTelegramInboundOriginTarget } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
@@ -240,7 +239,7 @@ export async function resolveTelegramInboundBody(params: {
const hasUserText = Boolean(rawText || locationText);
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
if (!rawBody) {
rawBody = resolveTelegramRichMessagePlaceholder(msg) ?? placeholder;
rawBody = placeholder;
}
if (!rawBody && allMedia.length === 0) {
return null;

View File

@@ -649,61 +649,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("renders default draft previews with standard Telegram HTML", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "# Heading" });
await dispatcherOptions.deliver({ text: "# Heading" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext() });
const params = expectDraftStreamParams({});
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
expect(renderText?.("# Heading")).toEqual({
text: "Heading",
parseMode: "HTML",
});
});
it("renders rich draft previews only when enabled", async () => {
resolveMarkdownTableMode.mockReturnValueOnce("block");
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({
text: "| A | B |\n| --- | --- |\n| 1 | 2 |",
});
await dispatcherOptions.deliver(
{ text: "| A | B |\n| --- | --- |\n| 1 | 2 |" },
{ kind: "final" },
);
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext(),
telegramCfg: { richMessages: true },
});
const params = expectDraftStreamParams({ richMessages: true });
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
expect(preview?.richMessage).toEqual(
expect.objectContaining({
html: expect.stringContaining("<table>"),
}),
);
});
it("recovers forum thread context from a topic-scoped session key", async () => {
const recordInboundSession = vi.fn(async () => undefined);
const oldHistoryKey = "-1003774691294:topic:1";
@@ -1576,7 +1521,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "partial" } },
});
expectDraftStreamParams({ maxChars: 4000 });
expectDraftStreamParams({ maxChars: 4096 });
});
it("streams text-only finals into the answer message", async () => {

View File

@@ -107,7 +107,6 @@ import {
shouldSuppressTelegramError,
} from "./error-policy.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import { renderTelegramHtmlText } from "./format.js";
import { includesRecentTelegramGroupHistoryContext } from "./group-history-context.js";
import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js";
import {
@@ -117,7 +116,6 @@ import {
type LaneDeliveryResult,
type LaneName,
} from "./lane-delivery.js";
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js";
import {
createTelegramReasoningStepState,
@@ -893,29 +891,20 @@ export const dispatchTelegramMessage = async ({
const draftMaxChars =
streamMode === "block"
? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit)
: Math.min(
textLimit,
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT,
);
: Math.min(textLimit, TELEGRAM_RICH_TEXT_LIMIT);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: route.accountId,
supportsBlockTables: telegramCfg.richMessages === true,
supportsBlockTables: true,
});
const renderStreamText = (text: string) => ({
text,
richMessage: buildTelegramRichMarkdown(text, {
tableMode,
skipEntityDetection: telegramCfg.linkPreview === false,
}),
});
const renderStreamText = (text: string): TelegramDraftPreview =>
telegramCfg.richMessages === true
? {
text,
richMessage: buildTelegramRichMarkdown(text, {
tableMode,
skipEntityDetection: telegramCfg.linkPreview === false,
}),
}
: {
text: renderTelegramHtmlText(text, { tableMode }),
parseMode: "HTML",
};
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(telegramCfg) ??
cfg.agents?.defaults?.blockStreamingDefault === "on";
@@ -999,7 +988,6 @@ export const dispatchTelegramMessage = async ({
maxChars: draftMaxChars,
thread: threadSpec,
replyToMessageId: draftReplyToMessageId,
richMessages: telegramCfg.richMessages,
minInitialChars: draftMinInitialChars,
renderText: renderStreamText,
onSupersededPreview: (superseded) => {
@@ -1519,7 +1507,6 @@ export const dispatchTelegramMessage = async ({
thread: threadSpec,
tableMode,
chunkMode,
richMessages: telegramCfg.richMessages,
linkPreview: telegramCfg.linkPreview,
replyQuoteMessageId,
replyQuoteText,

View File

@@ -703,25 +703,6 @@ describe("registerTelegramNativeCommands", () => {
expect(replyAt(deliverParams).isError).toBe(true);
});
it("uses rich messages for plugin command replies when enabled", async () => {
const { handler } = registerPlugCommand({
cfg: {
channels: {
telegram: {
richMessages: true,
},
},
},
registerOverrides: {
telegramCfg: { richMessages: true } as TelegramAccountConfig,
},
});
await handler(createPrivateCommandContext());
expect(firstDeliverRepliesParams().richMessages).toBe(true);
});
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
const { handler } = registerPlugCommand();

View File

@@ -973,7 +973,6 @@ export const registerTelegramNativeCommands = ({
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: TelegramChunkMode;
linkPreview?: boolean;
richMessages?: boolean;
}) => ({
cfg: params.cfg,
chatId: String(params.chatId),
@@ -993,7 +992,6 @@ export const registerTelegramNativeCommands = ({
tableMode: params.tableMode,
chunkMode: params.chunkMode,
linkPreview: params.linkPreview,
richMessages: params.richMessages,
});
const resolveCommandTargetSessionKey = (params: {
runtimeCfg: OpenClawConfig;
@@ -1211,7 +1209,6 @@ export const registerTelegramNativeCommands = ({
tableMode,
chunkMode,
linkPreview: runtimeTelegramCfg.linkPreview,
richMessages: runtimeTelegramCfg.richMessages,
});
let topicName: string | undefined;
if (isForum && resolvedThreadId != null) {
@@ -1434,7 +1431,6 @@ export const registerTelegramNativeCommands = ({
tableMode,
chunkMode,
linkPreview: runtimeTelegramCfg.linkPreview,
richMessages: runtimeTelegramCfg.richMessages,
});
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
const to = `telegram:${chatId}`;

View File

@@ -93,22 +93,6 @@ export function buildSenderLabel(msg: Message, senderId?: number | string) {
export type TelegramTextEntity = NonNullable<Message["entities"]>[number];
const TELEGRAM_RICH_MESSAGE_PLACEHOLDER = "[unsupported Telegram rich_message received]";
type TelegramTextMessage = Pick<Message, "text" | "caption" | "entities" | "caption_entities"> & {
rich_message?: unknown;
};
function hasTelegramRichMessage(value: unknown): boolean {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function resolveTelegramRichMessagePlaceholder(
msg: TelegramTextMessage,
): string | undefined {
return hasTelegramRichMessage(msg.rich_message) ? TELEGRAM_RICH_MESSAGE_PLACEHOLDER : undefined;
}
export function isBinaryContent(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
@@ -124,7 +108,9 @@ export function resolveTelegramTextContent(text: unknown, caption?: unknown): st
return isBinaryContent(raw) ? "" : raw;
}
export function getTelegramTextParts(msg: TelegramTextMessage): {
export function getTelegramTextParts(
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
): {
text: string;
entities: TelegramTextEntity[];
} {

View File

@@ -4,15 +4,15 @@ import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/channel-outbound";
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime";
import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import {
buildCanonicalSentMessageHookContext,
createInternalHookEvent,
fireAndForgetHook,
toInternalMessageSentContext,
toPluginMessageContext,
toPluginMessageSentEvent,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
@@ -23,7 +23,7 @@ import {
probeVideoDimensions,
} from "openclaw/plugin-sdk/media-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -32,13 +32,16 @@ import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../button-types.js";
import { splitTelegramCaption } from "../caption.js";
import {
markdownToTelegramChunks,
markdownToTelegramHtml,
renderTelegramHtmlText,
wrapFileReferencesInHtml,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
} from "../format.js";
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
import {
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
type TelegramRichTextChunk,
} from "../rich-message.js";
import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import {
@@ -57,6 +60,7 @@ import {
const VOICE_FORBIDDEN_MARKER = "VOICE_MESSAGES_FORBIDDEN";
const CAPTION_TOO_LONG_RE = /caption is too long/i;
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
@@ -76,61 +80,54 @@ type TelegramReplyQuoteForSend = {
entities?: unknown[];
};
type TelegramDeliveryTextChunk = {
text: string;
plainText: string;
textMode: "html";
};
type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[];
type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[];
function buildChunkTextResolver(params: {
textLimit: number;
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
richMessages?: boolean;
skipEntityDetection?: boolean;
chatType: "direct" | "group";
}): ChunkTextFn {
if (params.richMessages === true) {
return (markdown: string) =>
splitTelegramRichMessageTextChunks({
text: markdown,
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
textMode: "markdown",
chunkMode: params.chunkMode,
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
}
return (markdown: string) => {
const markdownChunks =
params.chunkMode === "newline"
? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode)
: [markdown];
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
for (const chunk of markdownChunks) {
const nested = markdownToTelegramChunks(chunk, params.textLimit, {
tableMode: params.tableMode,
});
if (!nested.length && chunk) {
chunks.push({
html: wrapFileReferencesInHtml(
markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }),
),
text: chunk,
});
continue;
}
chunks.push(...nested);
if (params.chatType === "group") {
return splitTelegramHtmlChunks(
renderTelegramHtmlText(markdown, { tableMode: params.tableMode }),
Math.min(params.textLimit, TELEGRAM_LEGACY_TEXT_LIMIT),
).map((text) => ({
text,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(text),
}));
}
return chunks.map((chunk) => ({
text: chunk.html,
plainText: chunk.text,
textMode: "html" as const,
}));
return splitTelegramRichMessageTextChunks({
text: markdown,
textLimit: params.textLimit,
textMode: "markdown",
chunkMode: params.chunkMode,
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
};
}
function resolveReplyChatType(params: {
chatId: string;
thread?: TelegramThreadSpec | null;
isGroup?: boolean;
}) {
if (params.isGroup === true) {
return "group";
}
if (params.thread?.scope === "dm") {
return "direct";
}
if (params.thread) {
return "group";
}
return params.chatId.trim().startsWith("-") ? "group" : "direct";
}
function markDelivered(progress: DeliveryProgress): void {
progress.hasDelivered = true;
progress.deliveredCount += 1;
@@ -194,10 +191,10 @@ async function deliverTextReply(params: {
replyQuoteText?: string;
replyQuotePosition?: number;
replyQuoteEntities?: unknown[];
richMessages?: boolean;
tableMode?: MarkdownTableMode;
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
chatType?: "direct" | "group";
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -226,12 +223,11 @@ async function deliverTextReply(params: {
replyQuoteEntities: params.replyQuoteEntities,
thread: params.thread,
textMode: chunk.textMode,
plainText: chunk.plainText,
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
chatType: params.chatType,
},
);
if (firstDeliveredMessageId == null) {
@@ -250,10 +246,10 @@ async function sendPendingFollowUpText(params: {
chunkText: ChunkTextFn;
text: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
richMessages?: boolean;
tableMode?: MarkdownTableMode;
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
chatType?: "direct" | "group";
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -271,12 +267,11 @@ async function sendPendingFollowUpText(params: {
replyToMessageId,
thread: params.thread,
textMode: chunk.textMode,
plainText: chunk.plainText,
richMessages: params.richMessages,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
chatType: params.chatType,
});
},
});
@@ -317,12 +312,12 @@ async function sendTelegramVoiceFallbackText(opts: {
replyQuotePosition?: number;
replyQuoteEntities?: unknown[];
thread?: TelegramThreadSpec | null;
richMessages?: boolean;
tableMode?: MarkdownTableMode;
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
chatType?: "direct" | "group";
}): Promise<number | undefined> {
let firstDeliveredMessageId: number | undefined;
const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text));
@@ -339,12 +334,11 @@ async function sendTelegramVoiceFallbackText(opts: {
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
thread: opts.thread,
textMode: chunk.textMode,
plainText: chunk.plainText,
richMessages: opts.richMessages,
linkPreview: opts.linkPreview,
tableMode: opts.tableMode,
silent: opts.silent,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
chatType: opts.chatType,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = messageId;
@@ -364,7 +358,6 @@ async function deliverMediaReply(params: {
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
tableMode?: MarkdownTableMode;
richMessages?: boolean;
mediaLocalRoots?: readonly string[];
mediaMaxBytes?: number;
chunkText: ChunkTextFn;
@@ -380,6 +373,7 @@ async function deliverMediaReply(params: {
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
chatType?: "direct" | "group";
}): Promise<{ firstDeliveredMessageId?: number; visibleFallbackText?: string }> {
let firstDeliveredMessageId: number | undefined;
let visibleFallbackText: string | undefined;
@@ -526,12 +520,11 @@ async function deliverMediaReply(params: {
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
thread: params.thread,
richMessages: params.richMessages,
tableMode: params.tableMode,
linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup: params.replyMarkup,
replyQuoteText: params.replyQuoteText,
chatType: params.chatType,
});
if (firstDeliveredMessageId == null) {
firstDeliveredMessageId = fallbackMessageId;
@@ -559,11 +552,10 @@ async function deliverMediaReply(params: {
chunkText: params.chunkText,
replyToId: undefined,
thread: params.thread,
richMessages: params.richMessages,
tableMode: params.tableMode,
linkPreview: params.linkPreview,
silent: params.silent,
replyMarkup: params.replyMarkup,
chatType: params.chatType,
});
visibleFallbackText = fallbackText;
}
@@ -610,10 +602,10 @@ async function deliverMediaReply(params: {
chunkText: params.chunkText,
text: pendingFollowUpText,
replyMarkup: params.replyMarkup,
richMessages: params.richMessages,
tableMode: params.tableMode,
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
chatType: params.chatType,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
@@ -744,8 +736,6 @@ export async function deliverReplies(params: {
thread?: TelegramThreadSpec | null;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
/** Opt into Telegram Bot API 10.1 rich text delivery. */
richMessages?: boolean;
/** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void;
/** Controls whether link previews are shown. Default: true (previews enabled). */
@@ -775,17 +765,19 @@ export async function deliverReplies(params: {
const transcriptMirror = params.transcriptMirror;
const deliveredContents: Array<{ text: string; mediaUrls: string[] }> = [];
const hookRunner = getGlobalHookRunner();
const replyChatType = resolveReplyChatType({
chatId: params.chatId,
thread: params.thread,
isGroup: params.mirrorIsGroup,
});
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
const chunkText = buildChunkTextResolver({
textLimit:
params.richMessages === true
? Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT)
: Math.min(params.textLimit, 4000),
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
chunkMode: params.chunkMode ?? "length",
tableMode: params.tableMode,
richMessages: params.richMessages,
skipEntityDetection: params.linkPreview === false,
chatType: replyChatType,
});
const candidateReplies: ReplyPayload[] = [];
for (const reply of params.replies) {
@@ -889,6 +881,9 @@ export async function deliverReplies(params: {
presentation,
interactive,
}),
{
chatType: replyChatType,
},
);
let firstDeliveredMessageId: number | undefined;
if (mediaList.length === 0) {
@@ -904,10 +899,10 @@ export async function deliverReplies(params: {
replyQuoteText: replyQuote.text,
replyQuotePosition: replyQuote.position,
replyQuoteEntities: replyQuote.entities,
richMessages: params.richMessages,
tableMode: params.tableMode,
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
chatType: replyChatType,
replyToId,
replyToMode: params.replyToMode,
progress,
@@ -921,7 +916,6 @@ export async function deliverReplies(params: {
runtime: params.runtime,
thread: params.thread,
tableMode: params.tableMode,
richMessages: params.richMessages,
mediaLocalRoots: params.mediaLocalRoots,
mediaMaxBytes: params.mediaMaxBytes,
chunkText,
@@ -929,6 +923,7 @@ export async function deliverReplies(params: {
onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview,
silent: params.silent,
chatType: replyChatType,
replyQuoteMessageId: replyQuote.messageId,
replyQuoteText: replyQuote.text,
replyQuotePosition: replyQuote.position,

View File

@@ -5,7 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { withTelegramApiErrorLogging } from "../api-logging.js";
import { markdownToTelegramHtml } from "../format.js";
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "../format.js";
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
import {
buildTelegramSendParams,
@@ -23,9 +23,8 @@ import type { TelegramThreadSpec } from "./helpers.js";
export { buildTelegramSendParams } from "../reply-parameters.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
@@ -36,6 +35,13 @@ function isTelegramQuoteParamError(err: unknown): boolean {
return QUOTE_PARAM_RE.test(formatErrorMessage(err));
}
function isTelegramHtmlParseError(err: unknown): boolean {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return PARSE_ERR_RE.test(err.description);
}
return PARSE_ERR_RE.test(formatErrorMessage(err));
}
function createTelegramDeliverySendRetry() {
return createTelegramRetryRunner({
shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err),
@@ -76,14 +82,14 @@ export async function sendTelegramWithThreadFallback<T>(params: {
} catch (err) {
if (hasNativeQuote && isTelegramQuoteParamError(err)) {
params.runtime.log?.(
`telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`,
`telegram ${params.operation}: native quote rejected; retrying without quote text`,
);
const removeNativeQuoteParam =
params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam;
return await sendTelegramWithThreadFallback({
...params,
operation: `${params.operation} (legacy reply retry)`,
requestParams: (params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam)(
params.requestParams,
),
operation: `${params.operation} (reply retry)`,
requestParams: removeNativeQuoteParam(params.requestParams),
});
}
throw err;
@@ -103,12 +109,11 @@ export async function sendTelegramText(
replyQuoteEntities?: unknown[];
thread?: TelegramThreadSpec | null;
textMode?: "markdown" | "html";
plainText?: string;
richMessages?: boolean;
linkPreview?: boolean;
tableMode?: MarkdownTableMode;
silent?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
chatType?: "direct" | "group";
},
): Promise<number> {
const baseParams = buildTelegramSendParams({
@@ -120,88 +125,86 @@ export async function sendTelegramText(
thread: opts?.thread,
silent: opts?.silent,
});
const richParams = toTelegramRichMessageContextParams(baseParams);
const textMode = opts?.textMode ?? "markdown";
if (opts?.richMessages === true) {
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode: opts.tableMode,
});
const res = await sendTelegramWithThreadFallback({
operation: "sendRichMessage",
runtime,
thread: opts.thread,
requestParams: toTelegramRichMessageContextParams(baseParams),
removeNativeQuoteParam: removeTelegramRichNativeQuoteParam,
send: (effectiveParams) =>
getTelegramRichRawApi(bot.api).sendRichMessage({
chat_id: chatId,
rich_message: richMessage,
...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
});
runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
}
// Add link_preview_options when link preview is disabled.
const linkPreviewEnabled = opts?.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const fallbackText = opts?.plainText ?? text;
const hasFallbackText = fallbackText.trim().length > 0;
const sendPlainFallback = async () => {
const res = await sendTelegramWithThreadFallback({
operation: "sendMessage",
runtime,
thread: opts?.thread,
requestParams: baseParams,
send: (effectiveParams) =>
bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
});
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
return res.message_id;
};
const normalizedChatId = chatId.trim();
const shouldUseRichText =
opts?.chatType !== "group" &&
(opts?.thread?.scope === "dm" || (!opts?.thread && !normalizedChatId.startsWith("-")));
// Markdown can render to empty HTML for syntax-only chunks; recover with plain text.
if (!htmlText.trim()) {
if (!hasFallbackText) {
throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback");
}
return await sendPlainFallback();
if (!text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
try {
const res = await sendTelegramWithThreadFallback({
operation: "sendMessage",
runtime,
thread: opts?.thread,
requestParams: baseParams,
shouldLog: (err) => {
const errText = formatErrorMessage(err);
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
},
send: (effectiveParams) =>
bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
if (!shouldUseRichText) {
const htmlText = renderTelegramHtmlText(text, {
textMode,
tableMode: opts?.tableMode,
});
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
} catch (err) {
const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
if (!hasFallbackText) {
const htmlParams: Record<string, unknown> = {
parse_mode: "HTML",
...(opts?.linkPreview === false ? { link_preview_options: { is_disabled: true } } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
};
const plainParams = { ...htmlParams };
delete plainParams.parse_mode;
const sendLegacy = async (
operation: string,
body: string,
requestParams: Record<string, unknown>,
) =>
await sendTelegramWithThreadFallback({
operation,
runtime,
thread: opts?.thread,
requestParams,
send: (effectiveParams) =>
bot.api.sendMessage(chatId, body, {
...effectiveParams,
}),
});
let res: Awaited<ReturnType<typeof sendLegacy>>;
try {
res = await sendLegacy("sendMessage", htmlText, htmlParams);
} catch (err) {
if (!isTelegramHtmlParseError(err)) {
throw err;
}
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
return await sendPlainFallback();
runtime.log?.(
`telegram sendMessage failed with HTML parse error; retrying as plain text: ${formatErrorMessage(
err,
)}`,
);
res = await sendLegacy(
"sendMessage (plain)",
telegramHtmlToPlainTextFallback(htmlText),
plainParams,
);
}
throw err;
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
}
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts?.linkPreview === false,
tableMode: opts?.tableMode,
});
const richRawApi = getTelegramRichRawApi(bot.api);
const res = await sendTelegramWithThreadFallback({
operation: "sendRichMessage",
runtime,
thread: opts?.thread,
requestParams: richParams,
removeNativeQuoteParam: removeTelegramRichNativeQuoteParam,
send: (effectiveParams) =>
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: richMessage,
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...effectiveParams,
}),
});
runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`);
return res.message_id;
}

View File

@@ -166,6 +166,10 @@ function firstSendText(mock: ReturnType<typeof vi.fn>) {
return text as string;
}
function rawSendRichMessageMock(bot: Bot): ReturnType<typeof vi.fn> {
return (bot.api.raw as unknown as { sendRichMessage: ReturnType<typeof vi.fn> }).sendRichMessage;
}
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
@@ -813,7 +817,7 @@ describe("deliverReplies", () => {
});
});
it("disables link previews without rich-only entity flags", async () => {
it("skips rich entity detection when link previews are disabled", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 3,
@@ -830,10 +834,99 @@ describe("deliverReplies", () => {
expect(firstMockCallArg(sendMessage, 0)).toBe("123");
firstSendText(sendMessage);
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
link_preview_options: { is_disabled: true },
expectRecordFields(mockCallArg(sendMessage, 0, 2), { skip_entity_detection: true });
});
it("uses Bot API sendMessage instead of rich messages for group replies", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 63,
chat: { id: "-1001234567890" },
});
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("skip_entity_detection");
const bot = createBot({ sendMessage });
await deliverReplies({
...baseDeliveryParams,
chatId: "-1001234567890",
replies: [{ text: "hi Mason" }],
runtime,
bot,
thread: { id: 456, scope: "forum" },
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("-1001234567890", "hi Mason", {
parse_mode: "HTML",
message_thread_id: 456,
});
});
it("treats mirrored group replies as group sends even when Telegram uses a positive chat id", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 64,
chat: { id: "584667058" },
});
const bot = createBot({ sendMessage });
await deliverReplies({
...baseDeliveryParams,
chatId: "584667058",
mirrorIsGroup: true,
mirrorGroupId: "-5278454993",
replies: [
{
text: "hi Mason",
channelData: {
telegram: {
buttons: [[{ text: "UPDATE", web_app: { url: "https://example.com/update" } }]],
},
},
},
],
runtime,
bot,
thread: { scope: "none" },
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith("584667058", "hi Mason", {
parse_mode: "HTML",
reply_markup: {
inline_keyboard: [[{ text: "UPDATE", url: "https://example.com/update" }]],
},
});
});
it("chunks mirrored group replies for the Bot API sendMessage limit", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ message_id: 65, chat: { id: "584667058" } })
.mockResolvedValueOnce({ message_id: 66, chat: { id: "584667058" } });
const bot = createBot({ sendMessage });
const longText = "a".repeat(4100);
await deliverReplies({
...baseDeliveryParams,
chatId: "584667058",
mirrorIsGroup: true,
mirrorGroupId: "-5278454993",
replies: [{ text: longText }],
runtime,
bot,
thread: { scope: "none" },
textLimit: 100_000,
});
expect(rawSendRichMessageMock(bot)).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledTimes(2);
const firstText = firstSendText(sendMessage);
const secondText = mockCallArg(sendMessage, 1, 1);
expect(typeof secondText).toBe("string");
expect(firstText.length).toBeLessThanOrEqual(4096);
expect((secondText as string).length).toBeLessThanOrEqual(4096);
expect(`${firstText}${secondText as string}`).toBe(longText);
});
it("includes message_thread_id for DM topics", async () => {
@@ -1100,48 +1193,6 @@ describe("deliverReplies", () => {
}
});
it("retries rich messages without converting reply parameters to legacy fields", async () => {
const runtime = createRuntime();
const sendMessage = vi
.fn()
.mockRejectedValueOnce(createQuoteNotFoundError())
.mockResolvedValueOnce({
message_id: 11,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
await deliverWith({
replies: [{ text: "Hello there", replyToId: "500" }],
runtime,
bot,
replyToMode: "all",
replyQuoteMessageId: 500,
replyQuoteText: " quoted text\n",
richMessages: true,
});
const raw = bot.api.raw as unknown as {
sendRichMessage: ReturnType<typeof vi.fn>;
};
const { sendRichMessage } = raw;
expect(sendRichMessage).toHaveBeenCalledTimes(2);
expectRecordFields(firstMockCallArg(sendRichMessage, 0), {
reply_parameters: {
message_id: 500,
quote: " quoted text\n",
allow_sending_without_reply: true,
},
});
expectRecordFields(mockCallArg(sendRichMessage, 1, 0), {
reply_parameters: {
message_id: 500,
allow_sending_without_reply: true,
},
});
expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id");
});
it("uses legacy reply id when selected reply target differs from quote source", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -507,24 +507,6 @@ describe("describeReplyTarget", () => {
expect(result?.kind).toBe("reply");
});
it("describes rich-message-only reply targets with a sanitized placeholder", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
rich_message: { blocks: [{ type: "paragraph" }] },
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
expect(result?.body).toBe("[unsupported Telegram rich_message received]");
expect(result?.quoteSourceText).toBeUndefined();
});
it("drops binary reply captions with no safe fallback", () => {
const result = describeReplyTarget({
message_id: 2,
@@ -728,23 +710,6 @@ describe("isBinaryContent", () => {
});
describe("getTelegramTextParts — binary caption filtering (#66647)", () => {
it("keeps rich-message-only updates out of canonical text", () => {
const result = getTelegramTextParts({
rich_message: { blocks: [{ type: "paragraph" }] },
});
expect(result).toEqual({ text: "", entities: [] });
});
it("keeps normal text when Telegram also supplies a rich message", () => {
const result = getTelegramTextParts({
text: "normal text",
rich_message: { blocks: [{ type: "paragraph" }] },
});
expect(result).toEqual({ text: "normal text", entities: [] });
});
it("strips binary caption content to prevent token explosion", () => {
const binaryCaption = "PK\x03\x04\x14\x00\x08binary-ebook-data";
const result = getTelegramTextParts({

View File

@@ -40,7 +40,6 @@ import {
renderTelegramTextEntities,
resolveTelegramTextContent,
resolveTelegramMediaPlaceholder,
resolveTelegramRichMessagePlaceholder,
type TelegramForwardedContext,
type TelegramTextEntity,
} from "./body-helpers.js";
@@ -57,7 +56,6 @@ export {
normalizeForwardedContext,
renderTelegramTextEntities,
resolveTelegramMediaPlaceholder,
resolveTelegramRichMessagePlaceholder,
};
const TELEGRAM_GENERAL_TOPIC_ID = 1;
@@ -621,12 +619,11 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
: replyLike && typeof replyLike.caption === "string"
? replyLike.caption
: undefined;
const replyTextParts = replyLike ? getTelegramTextParts(replyLike) : undefined;
const safeReplyText = replyTextParts?.text ?? "";
const safeReplyText = resolveTelegramTextContent(rawReplyText);
const replyTextParts = replyLike && safeReplyText ? getTelegramTextParts(replyLike) : undefined;
let filteredReplyText = false;
if (!body && replyLike) {
const replyBody =
safeReplyText.trim() || resolveTelegramRichMessagePlaceholder(replyLike) || "";
const replyBody = safeReplyText.trim();
filteredReplyText = hadUnsafeTelegramText(rawReplyText, replyBody);
body = replyBody;
if (!body) {

View File

@@ -23,78 +23,12 @@ describe("telegram actions contract", () => {
],
});
it.each([
{ richMessages: undefined, expected: false },
{ richMessages: false, expected: false },
{ richMessages: true, expected: true },
])("advertises Telegram rich text only when enabled", ({ richMessages, expected }) => {
it("advertises Telegram rich text to the agent prompt", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
richMessages,
},
},
} as OpenClawConfig,
});
expect(capabilities).toContain("inlineButtons");
expect(capabilities?.includes("richText")).toBe(expected);
});
it("uses the selected Telegram account's rich text setting", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
richMessages: true,
accounts: {
ops: {
richMessages: false,
},
},
},
},
} as OpenClawConfig,
accountId: "ops",
});
expect(capabilities).not.toContain("richText");
});
it("does not resolve Telegram credentials while checking prompt capabilities", () => {
expect(() =>
telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
tokenFile: "/definitely/missing/telegram-token",
richMessages: true,
},
},
} as OpenClawConfig,
}),
).not.toThrow();
});
it("uses the configured default Telegram account for prompt capabilities", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
defaultAccount: "ops",
accounts: {
default: {
botToken: "123:default-token",
richMessages: false,
},
ops: {
botToken: "123:ops-token",
richMessages: true,
},
},
},
},
} as OpenClawConfig,

View File

@@ -36,12 +36,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
mergeTelegramAccountConfig,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
type ResolvedTelegramAccount,
} from "./accounts.js";
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js";
import { resolveTelegramAutoThreadId } from "./action-threading.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { telegramApprovalCapability } from "./approval-native.js";
@@ -788,12 +783,7 @@ export const telegramPlugin = createChatChannelPlugin({
cfg,
accountId: accountId ?? undefined,
});
const capabilities = inlineButtonsScope === "off" ? [] : ["inlineButtons"];
const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg);
if (mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true) {
capabilities.push("richText");
}
return capabilities;
return inlineButtonsScope === "off" ? ["richText"] : ["inlineButtons", "richText"];
},
reactionGuidance: ({ cfg, accountId }) => {
const level = resolveTelegramReactionLevel({

View File

@@ -153,19 +153,6 @@ describe("telegram custom commands schema", () => {
}
});
it("accepts rich message opt-in per account", () => {
const res = TelegramConfigSchema.safeParse({
richMessages: true,
accounts: { ops: { richMessages: false } },
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.richMessages).toBe(true);
expect(res.data.accounts?.ops?.richMessages).toBe(false);
}
});
it("normalizes custom commands", () => {
const res = TelegramConfigSchema.safeParse({
customCommands: [{ command: "/Backup", description: " Git backup " }],

View File

@@ -62,10 +62,6 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Chunk Mode",
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
},
richMessages: {
label: "Telegram Rich Messages",
help: "Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported.",
},
"streaming.block.enabled": {
label: "Telegram Block Streaming Enabled",
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',

View File

@@ -2,7 +2,7 @@
import type { Bot } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTelegramDraftStream } from "./draft-stream.js";
import type { TelegramInputRichMessage } from "./rich-message.js";
import { markdownToTelegramRichHtml } from "./format.js";
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
@@ -47,56 +47,50 @@ async function expectInitialForumSend(
text = "Hello",
): Promise<void> {
await vi.waitFor(() =>
expect(api.sendMessage).toHaveBeenCalledWith(123, text, {
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: markdownToTelegramRichHtml(text) },
message_thread_id: 99,
}),
);
}
function expectPreviewSend(
function expectRichSend(
api: ReturnType<typeof createMockDraftApi>,
text: string,
params: Record<string, unknown> = {},
) {
expect(api.sendMessage).toHaveBeenCalledWith(123, text, params);
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: markdownToTelegramRichHtml(text) },
...params,
});
}
function expectNthPreviewSend(
function expectNthRichSend(
api: ReturnType<typeof createMockDraftApi>,
call: number,
text: string,
params: Record<string, unknown> = {},
) {
expect(api.sendMessage).toHaveBeenNthCalledWith(call, 123, text, params);
expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, {
chat_id: 123,
rich_message: { html: markdownToTelegramRichHtml(text) },
...params,
});
}
function requireSendMessageCallText(
api: ReturnType<typeof createMockDraftApi>,
callIndex: number,
): string {
const calls = api.sendMessage.mock.calls as unknown[][];
const call = calls[callIndex];
expect(call, `sendMessage call ${callIndex}`).toBeDefined();
const text = call?.[1];
expect(typeof text).toBe("string");
return typeof text === "string" ? text : "";
}
function expectPreviewEdit(
api: ReturnType<typeof createMockDraftApi>,
text: string,
params?: Record<string, unknown>,
) {
if (params) {
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text, params);
return;
}
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, text);
function expectRichEdit(api: ReturnType<typeof createMockDraftApi>, text: string) {
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: markdownToTelegramRichHtml(text) },
});
}
function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
const api = createMockDraftApi();
api.sendMessage
api.raw.sendRichMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 });
const stream = createDraftStream(
@@ -121,12 +115,12 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello");
await expectInitialForumSend(api);
await (api.sendMessage.mock.results[0]?.value as Promise<unknown>);
await (api.raw.sendRichMessage.mock.results[0]?.value as Promise<unknown>);
stream.update("Hello again");
await stream.flush();
expectPreviewEdit(api, "Hello again");
expectRichEdit(api, "Hello again");
});
it("waits for in-flight updates before final flush edit", async () => {
@@ -138,15 +132,15 @@ describe("createTelegramDraftStream", () => {
const stream = createForumDraftStream(api);
stream.update("Hello");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
stream.update("Hello final");
const flushPromise = stream.flush();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.raw.editMessageText).not.toHaveBeenCalled();
resolveSend?.({ message_id: 17 });
await flushPromise;
expectPreviewEdit(api, "Hello final");
expectRichEdit(api, "Hello final");
});
it("omits message_thread_id for general topic id", async () => {
@@ -155,21 +149,21 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello");
await vi.waitFor(() => expectPreviewSend(api, "Hello"));
await vi.waitFor(() => expectRichSend(api, "Hello"));
});
it("uses text send/edit for dm thread previews", async () => {
it("uses rich send/edit for dm thread previews", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await vi.waitFor(() => expectPreviewSend(api, "Hello", { message_thread_id: 42 }));
expect(api.editMessageText).not.toHaveBeenCalled();
await vi.waitFor(() => expectRichSend(api, "Hello", { message_thread_id: 42 }));
expect(api.raw.editMessageText).not.toHaveBeenCalled();
stream.update("Hello again");
await stream.flush();
expectPreviewEdit(api, "Hello again");
expectRichEdit(api, "Hello again");
});
it("tracks when a message preview first became visible", async () => {
@@ -198,7 +192,7 @@ describe("createTelegramDraftStream", () => {
"does not retry %s message preview sends without the topic id",
async (scope) => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(
api.raw.sendRichMessage.mockRejectedValueOnce(
new Error("400: Bad Request: message thread not found"),
);
const warn = vi.fn();
@@ -210,8 +204,8 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expectRichSend(api, "Hello", { message_thread_id: 42 });
expect(warn).toHaveBeenCalledWith(
"telegram stream preview failed: 400: Bad Request: message thread not found",
);
@@ -223,7 +217,7 @@ describe("createTelegramDraftStream", () => {
it("does not finalize stale preview text after a stopped send failure", async () => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(new Error("temporary send failure"));
api.raw.sendRichMessage.mockRejectedValueOnce(new Error("temporary send failure"));
const warn = vi.fn();
const stream = createDraftStream(api, { warn });
@@ -231,8 +225,8 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
await stream.stop();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectPreviewSend(api, "Hello");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expectRichSend(api, "Hello");
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: temporary send failure");
});
@@ -246,7 +240,7 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello");
await stream.flush();
expectPreviewSend(api, "Hello", {
expectRichSend(api, "Hello", {
message_thread_id: 42,
reply_parameters: {
message_id: 411,
@@ -255,13 +249,13 @@ describe("createTelegramDraftStream", () => {
});
});
it("materializes message previews using rendered HTML text", async () => {
it("materializes message previews using rendered rich HTML", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
renderText: (text) => ({
text: text.replace("**bold**", "<b>bold</b>"),
parseMode: "HTML",
richMessage: { html: text.replace("**bold**", "<b>bold</b>") },
}),
});
@@ -270,11 +264,12 @@ describe("createTelegramDraftStream", () => {
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
parse_mode: "HTML",
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: "<b>bold</b>" },
message_thread_id: 42,
});
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
});
it("returns existing preview id when materializing message transport", async () => {
@@ -288,8 +283,7 @@ describe("createTelegramDraftStream", () => {
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
});
it("deletes message preview on clear after finalization", async () => {
@@ -302,8 +296,8 @@ describe("createTelegramDraftStream", () => {
await stream.stop();
await stream.clear();
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
expectPreviewEdit(api, "Hello again");
expectRichSend(api, "Hello", { message_thread_id: 42 });
expectRichEdit(api, "Hello again");
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
});
@@ -313,12 +307,12 @@ describe("createTelegramDraftStream", () => {
// First message
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
// Normal edit (same message)
stream.update("Hello edited");
await stream.flush();
expectPreviewEdit(api, "Hello edited");
expectRichEdit(api, "Hello edited");
// Force new message (e.g. after thinking block ends)
stream.forceNewMessage();
@@ -326,8 +320,8 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
// Should have sent a second new message, not edited the first
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 2, "After thinking");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expectNthRichSend(api, 2, "After thinking");
});
it("creates new message after cleanup and forceNewMessage", async () => {
@@ -343,8 +337,8 @@ describe("createTelegramDraftStream", () => {
stream.update("Next preview");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 2, "Next preview");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expectNthRichSend(api, 2, "Next preview");
});
it("sends first update immediately after forceNewMessage within throttle window", async () => {
@@ -353,15 +347,15 @@ describe("createTelegramDraftStream", () => {
const { api, stream } = createForceNewMessageHarness({ throttleMs: 1000 });
stream.update("Hello");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
stream.update("Hello edited");
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.raw.editMessageText).not.toHaveBeenCalled();
stream.forceNewMessage();
stream.update("Second message");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(2));
expectNthPreviewSend(api, 2, "Second message");
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2));
expectNthRichSend(api, 2, "Second message");
} finally {
vi.useRealTimers();
}
@@ -373,12 +367,14 @@ describe("createTelegramDraftStream", () => {
resolveFirstSend = resolve;
});
const api = createMockDraftApi();
api.sendMessage.mockReturnValueOnce(firstSend).mockResolvedValueOnce({ message_id: 42 });
api.raw.sendRichMessage
.mockReturnValueOnce(firstSend)
.mockResolvedValueOnce({ message_id: 42 });
const onSupersededPreview = vi.fn();
const stream = createDraftStream(api, { onSupersededPreview });
stream.update("Message A partial");
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
stream.forceNewMessage();
stream.update("Message B partial");
@@ -396,38 +392,44 @@ describe("createTelegramDraftStream", () => {
});
expect(typeof supersededPreview.visibleSinceMs).toBe("number");
expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true);
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 2, "Message B partial");
expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expectNthRichSend(api, 2, "Message B partial");
expect(api.raw.editMessageText).not.toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: markdownToTelegramRichHtml("Message B partial") },
});
});
it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send"));
api.raw.sendRichMessage.mockRejectedValueOnce(
new Error("timeout after Telegram accepted send"),
);
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expect(stream.sendMayHaveLanded?.()).toBe(true);
});
async function expectSendMayHaveLandedStateAfterFirstFailure(error: Error, expected: boolean) {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(error);
api.raw.sendRichMessage.mockRejectedValueOnce(error);
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expect(stream.sendMayHaveLanded?.()).toBe(expected);
}
it("retries pre-connect first preview send failures instead of stopping", async () => {
const api = createMockDraftApi();
api.sendMessage.mockRejectedValueOnce(
api.raw.sendRichMessage.mockRejectedValueOnce(
Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
);
const stream = createDraftStream(api);
@@ -436,7 +438,7 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expect(stream.sendMayHaveLanded?.()).toBe(false);
expect(stream.messageId()).toBe(17);
});
@@ -450,7 +452,7 @@ describe("createTelegramDraftStream", () => {
it("treats message-is-not-modified edits as delivered", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
api.raw.editMessageText.mockRejectedValueOnce(
Object.assign(
new Error("Call to 'editMessageText' failed! (400: Bad Request: message is not modified)"),
{ error_code: 400 },
@@ -466,14 +468,18 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello more");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
});
expect(warn).not.toHaveBeenCalled();
});
it("retries the preview edit after a transient network failure", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
api.raw.editMessageText.mockRejectedValueOnce(
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
);
const warn = vi.fn();
@@ -489,8 +495,12 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello again");
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: markdownToTelegramRichHtml("Hello again") },
});
expect(stream.lastDeliveredText?.()).toBe("Hello again");
});
@@ -498,7 +508,7 @@ describe("createTelegramDraftStream", () => {
vi.useFakeTimers();
try {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValueOnce(
api.raw.editMessageText.mockRejectedValueOnce(
Object.assign(
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 1)"),
{ error_code: 429, parameters: { retry_after: 1 } },
@@ -512,13 +522,17 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
stream.update("Hello more");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(1);
expect(api.raw.editMessageText).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1100);
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(2);
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
});
} finally {
vi.useRealTimers();
}
@@ -526,7 +540,7 @@ describe("createTelegramDraftStream", () => {
it("stops the preview after repeated retryable edit failures", async () => {
const api = createMockDraftApi();
api.editMessageText.mockRejectedValue(
api.raw.editMessageText.mockRejectedValue(
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
);
const warn = vi.fn();
@@ -541,32 +555,35 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledTimes(4);
expect(api.raw.editMessageText).toHaveBeenCalledTimes(4);
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: read ECONNRESET");
});
it("supports rendered previews with HTML parse mode", async () => {
it("supports rendered previews with rich HTML", async () => {
const api = createMockDraftApi();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
renderText: (text) => ({ text: `<i>${text}</i>`, parseMode: "HTML" }),
renderText: (text) => ({ text: `<i>${text}</i>`, richMessage: { html: `<i>${text}</i>` } }),
});
stream.update("hello");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<i>hello</i>", {
parse_mode: "HTML",
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: "<i>hello</i>" },
});
stream.update("hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "<i>hello again</i>", {
parse_mode: "HTML",
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: "<i>hello again</i>" },
});
});
it("sends caller-provided rich previews through standard text transport", async () => {
it("uses caller-provided rich previews", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api);
@@ -579,10 +596,13 @@ describe("createTelegramDraftStream", () => {
});
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b><br><b>🛠️ Exec</b>", {
parse_mode: "HTML",
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
@@ -593,76 +613,43 @@ describe("createTelegramDraftStream", () => {
});
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(
123,
17,
"<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
{ parse_mode: "HTML" },
);
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("uses rich send and edit for previews when explicitly enabled", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true });
stream.updatePreview({
text: "Plan",
richMessage: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
});
await stream.flush();
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
});
expect(api.sendMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Plan updated",
richMessage: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
});
await stream.flush();
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
rich_message: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
skip_entity_detection: true,
},
});
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("clamps rich previews to the block limit", async () => {
const api = createMockDraftApi();
const text = Array.from({ length: 501 }, (_, index) => `paragraph ${index}`).join("\n\n");
const stream = createDraftStream(api, { richMessages: true });
stream.update(text);
await stream.flush();
const calls = api.raw.sendRichMessage.mock.calls as unknown[][];
const params = calls[0]?.[0] as { rich_message?: TelegramInputRichMessage } | undefined;
const richMessage = params?.rich_message;
expect(richMessage?.html).toContain("paragraph 499");
expect(richMessage?.html).not.toContain("paragraph 500");
});
it("clamps rendered previews to the text-message limit", async () => {
const api = createMockDraftApi();
it("keeps rich rendered previews above the old text-message limit", async () => {
const richApi = {
sendRichMessage: vi.fn(async () => ({ message_id: 17 })),
editMessageText: vi.fn(async () => true),
};
const api = {
...createMockDraftApi(),
raw: richApi,
};
const text = `# Long\n\n${"rich line\n".repeat(600)}`;
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
chatId: 123,
renderText: (value) => ({ text: value }),
renderText: (value) => ({
text: value,
richMessage: { html: markdownToTelegramRichHtml(value) },
}),
});
stream.update(text);
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
const sentText = requireSendMessageCallText(api, 0);
expect(sentText.length).toBeLessThanOrEqual(4000);
expect(sentText.startsWith("# Long\n\nrich line")).toBe(true);
expect(richApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) },
});
expect(api.sendMessage).not.toHaveBeenCalled();
});
it("keeps non-final overflow in one editable preview", async () => {
@@ -675,9 +662,9 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello world foo bar baz qux");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectNthPreviewSend(api, 1, "Hello world");
expectPreviewEdit(api, "Hello world foo bar");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expectNthRichSend(api, 1, "Hello world");
expectRichEdit(api, "Hello world foo bar");
expect(onSupersededPreview).not.toHaveBeenCalled();
expect(stream.lastDeliveredText?.()).toBe("Hello world foo bar");
});
@@ -695,14 +682,14 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello world foo bar baz qux");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectPreviewEdit(api, "Hello world foo bar");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expectRichEdit(api, "Hello world foo bar");
expect(onSupersededPreview).not.toHaveBeenCalled();
});
it("continues in a new message when a final rendered preview crosses maxChars", async () => {
const api = createMockDraftApi();
api.sendMessage
api.raw.sendRichMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 });
const stream = createDraftStream(api, { maxChars: 20 });
@@ -712,9 +699,9 @@ describe("createTelegramDraftStream", () => {
stream.update("Hello world foo bar baz qux");
await stream.stop();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 1, "Hello world");
expectNthPreviewSend(api, 2, "foo bar baz qux");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expectNthRichSend(api, 1, "Hello world");
expectNthRichSend(api, 2, "foo bar baz qux");
});
it("clamps a first oversized non-final preview", async () => {
@@ -724,14 +711,14 @@ describe("createTelegramDraftStream", () => {
stream.update("1234567890ABCDEFGHIJ");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expectNthPreviewSend(api, 1, "1234567890");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
expectNthRichSend(api, 1, "1234567890");
expect(stream.lastDeliveredText?.()).toBe("1234567890");
});
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {
const api = createMockDraftApi();
api.sendMessage
api.raw.sendRichMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 });
const onSupersededPreview = vi.fn();
@@ -744,9 +731,9 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
await stream.stop();
expect(api.sendMessage).toHaveBeenCalledTimes(2);
expectNthPreviewSend(api, 1, "1234567890");
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
expectNthRichSend(api, 1, "1234567890");
expectNthRichSend(api, 2, "ABCDEFGHIJ");
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJ");
expect(onSupersededPreview).toHaveBeenCalledWith(
expect.objectContaining({
@@ -758,7 +745,7 @@ describe("createTelegramDraftStream", () => {
it("continues finalizing more than two overflow chunks after a clamped preview", async () => {
const api = createMockDraftApi();
api.sendMessage
api.raw.sendRichMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 })
.mockResolvedValueOnce({ message_id: 43 });
@@ -768,16 +755,16 @@ describe("createTelegramDraftStream", () => {
await stream.flush();
await stream.stop();
expect(api.sendMessage).toHaveBeenCalledTimes(3);
expectNthPreviewSend(api, 1, "1234567890");
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
expectNthPreviewSend(api, 3, "KLMNOPQRST");
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(3);
expectNthRichSend(api, 1, "1234567890");
expectNthRichSend(api, 2, "ABCDEFGHIJ");
expectNthRichSend(api, 3, "KLMNOPQRST");
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJKLMNOPQRST");
});
it("retains final overflow preview pages", async () => {
const api = createMockDraftApi();
api.sendMessage
api.raw.sendRichMessage
.mockResolvedValueOnce({ message_id: 17 })
.mockResolvedValueOnce({ message_id: 42 });
const onSupersededPreview = vi.fn();
@@ -811,8 +798,8 @@ describe("createTelegramDraftStream", () => {
chatId: 123,
maxChars: 100,
renderText: () => ({
text: `<b>${"<".repeat(120)}</b>`,
parseMode: "HTML",
text: "short raw text",
richMessage: { html: `<b>${"<".repeat(120)}</b>` },
}),
warn,
});
@@ -820,8 +807,8 @@ describe("createTelegramDraftStream", () => {
stream.update("short raw text");
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
expect(api.raw.editMessageText).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith("telegram stream preview stopped (text length 127 > 100)");
});
});
@@ -854,7 +841,7 @@ describe("draft stream initial message debounce", () => {
await stream.stop();
await stream.flush();
expectPreviewSend(api, "Y");
expectRichSend(api, "Y");
});
it("sends immediately on stop() with short sentence", async () => {
@@ -865,7 +852,7 @@ describe("draft stream initial message debounce", () => {
await stream.stop();
await stream.flush();
expectPreviewSend(api, "Ok.");
expectRichSend(api, "Ok.");
});
});
@@ -877,7 +864,7 @@ describe("draft stream initial message debounce", () => {
stream.update("Processing");
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
});
it("does not send a first message when discard() supersedes a short partial", async () => {
@@ -888,8 +875,8 @@ describe("draft stream initial message debounce", () => {
await stream.discard?.();
await stream.flush();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("sends first message when reaching threshold", async () => {
@@ -899,7 +886,7 @@ describe("draft stream initial message debounce", () => {
stream.update("I am processing your request..");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalled();
expect(api.raw.sendRichMessage).toHaveBeenCalled();
});
it("works with longer text above threshold", async () => {
@@ -909,7 +896,7 @@ describe("draft stream initial message debounce", () => {
stream.update("I am processing your request, please wait a moment");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalled();
expect(api.raw.sendRichMessage).toHaveBeenCalled();
});
});
@@ -920,18 +907,18 @@ describe("draft stream initial message debounce", () => {
stream.update("I am processing your request..");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
stream.update("I am processing your request.. and summarizing");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalled();
expect(api.sendMessage).toHaveBeenCalledTimes(1);
expect(api.raw.editMessageText).toHaveBeenCalled();
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
});
});
describe("default behavior without debounce params", () => {
it("sends plain preview text immediately without minInitialChars set", async () => {
it("sends rich markdown immediately without minInitialChars set", async () => {
const api = createMockApi();
const stream = createTelegramDraftStream({
api: api as unknown as Bot["api"],
@@ -941,7 +928,7 @@ describe("draft stream initial message debounce", () => {
stream.update("Hi");
await stream.flush();
expectPreviewSend(api, "Hi");
expectRichSend(api, "Hi");
});
});
});

View File

@@ -6,7 +6,6 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
@@ -15,20 +14,17 @@ import {
isTelegramRateLimitError,
readTelegramRetryAfterMs,
} from "./network-errors.js";
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
import {
buildTelegramRichMarkdown,
getTelegramRichRawApi,
isTelegramRichMessageWithinStructuralLimits,
TELEGRAM_RICH_TEXT_LIMIT,
getTelegramRichRawApi,
type TelegramInputRichMessage,
type TelegramSendRichMessageParams,
} from "./rich-message.js";
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_LIMIT;
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_RICH_TEXT_LIMIT;
const DEFAULT_THROTTLE_MS = 1000;
const TELEGRAM_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
// Retryable preview failures keep the latest text pending for the next throttle
// tick; cap consecutive misses so a persistent outage stops the preview instead
// of warn-spamming for the rest of the run.
@@ -59,8 +55,7 @@ export type TelegramDraftStream = {
export type TelegramDraftPreview = {
text: string;
parseMode?: "HTML";
richMessage?: TelegramInputRichMessage;
richMessage: TelegramInputRichMessage;
};
type SupersededTelegramPreview = {
@@ -70,76 +65,29 @@ type SupersededTelegramPreview = {
retain?: boolean;
};
type TelegramDraftTransportPreview = {
plainText: string;
text: string;
parseMode?: "HTML";
};
function renderTelegramDraftPreview(
text: string,
renderText: ((text: string) => TelegramDraftPreview) | undefined,
): TelegramDraftPreview {
const trimmed = text.trimEnd();
return renderText?.(trimmed) ?? { text: trimmed };
}
function isTelegramHtmlParseError(err: unknown): boolean {
return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err));
}
function normalizeTelegramDraftTransportPreview(
preview: TelegramDraftPreview,
): TelegramDraftTransportPreview {
if (preview.richMessage?.html) {
return {
text: preview.richMessage.html,
parseMode: "HTML",
plainText: preview.text,
};
}
if (preview.richMessage?.markdown) {
return {
text: renderTelegramHtmlText(preview.richMessage.markdown),
parseMode: "HTML",
plainText: preview.text,
};
}
if (preview.parseMode === "HTML") {
return {
text: preview.text,
parseMode: "HTML",
plainText: telegramHtmlToPlainTextFallback(preview.text),
};
}
return {
text: preview.text,
plainText: preview.text,
};
return (
renderText?.(trimmed) ?? { text: trimmed, richMessage: buildTelegramRichMarkdown(trimmed) }
);
}
function telegramDraftPreviewKey(preview: TelegramDraftPreview): string {
return JSON.stringify({
text: preview.text,
parseMode: preview.parseMode ?? "plain",
richMessage: preview.richMessage,
});
return JSON.stringify(preview.richMessage);
}
function telegramDraftRichPayloadLength(preview: TelegramDraftPreview): number {
const sourceMessage = preview.richMessage ?? { markdown: preview.text };
if (!isTelegramRichMessageWithinStructuralLimits(sourceMessage)) {
return TELEGRAM_RICH_TEXT_LIMIT + 1;
}
const richMessage = preview.richMessage ?? buildTelegramRichMarkdown(preview.text);
return richMessage.html?.length ?? richMessage.markdown?.length ?? 0;
function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number {
const richMessage = preview.richMessage;
return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length;
}
function findTelegramDraftChunkLength(
text: string,
maxChars: number,
renderText: ((text: string) => TelegramDraftPreview) | undefined,
richMessages: boolean,
): number {
let best = 0;
let low = 1;
@@ -147,11 +95,7 @@ function findTelegramDraftChunkLength(
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText);
const renderedText = normalizeTelegramDraftTransportPreview(preview).text.trimEnd();
const payloadLength = richMessages
? telegramDraftRichPayloadLength(preview)
: renderedText.length;
if (renderedText && payloadLength <= maxChars) {
if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) {
best = mid;
low = mid + 1;
} else {
@@ -167,7 +111,6 @@ export function createTelegramDraftStream(params: {
maxChars?: number;
thread?: TelegramThreadSpec | null;
replyToMessageId?: number;
richMessages?: boolean;
throttleMs?: number;
/** Minimum chars before sending first message (debounce for push notifications) */
minInitialChars?: number;
@@ -178,25 +121,16 @@ export function createTelegramDraftStream(params: {
log?: (message: string) => void;
warn?: (message: string) => void;
}): TelegramDraftStream {
const richMessages = params.richMessages === true;
const transportLimit = richMessages ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_STREAM_MAX_CHARS;
const maxChars = Math.min(params.maxChars ?? transportLimit, transportLimit);
const maxChars = Math.min(
params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS,
TELEGRAM_STREAM_MAX_CHARS,
);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = params.minInitialChars;
const chatId = params.chatId;
const threadParams = buildTelegramThreadParams(params.thread);
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
const sendMessageParams =
replyToMessageId != null
? {
...threadParams,
reply_parameters: {
message_id: replyToMessageId,
allow_sending_without_reply: true,
},
}
: (threadParams ?? {});
const richMessageParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
const richReplyParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
replyToMessageId != null
? {
...threadParams,
@@ -225,60 +159,25 @@ export function createTelegramDraftStream(params: {
sendGeneration: number;
};
const sendRenderedMessage = async (preview: TelegramDraftPreview) => {
if (richMessages) {
return await getTelegramRichRawApi(params.api).sendRichMessage({
chat_id: chatId,
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
...richMessageParams,
});
}
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
const sendPlain = async () =>
await params.api.sendMessage(chatId, transportPreview.plainText, sendMessageParams);
if (transportPreview.parseMode !== "HTML") {
return await sendPlain();
}
try {
return await params.api.sendMessage(chatId, transportPreview.text, {
parse_mode: "HTML" as const,
...sendMessageParams,
});
} catch (err) {
if (!isTelegramHtmlParseError(err)) {
throw err;
}
return await sendPlain();
}
const richRawApi = getTelegramRichRawApi(params.api);
return await richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: preview.richMessage,
...richReplyParams,
});
};
const sendMessageTransportPreview = async ({
preview,
sendGeneration,
}: PreviewSendParams): Promise<boolean> => {
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
if (typeof streamMessageId === "number") {
streamVisibleSinceMs ??= Date.now();
if (richMessages) {
await getTelegramRichRawApi(params.api).editMessageText({
chat_id: chatId,
message_id: streamMessageId,
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
});
return true;
}
if (transportPreview.parseMode === "HTML") {
try {
await params.api.editMessageText(chatId, streamMessageId, transportPreview.text, {
parse_mode: "HTML" as const,
});
} catch (err) {
if (!isTelegramHtmlParseError(err)) {
throw err;
}
await params.api.editMessageText(chatId, streamMessageId, transportPreview.plainText);
}
} else {
await params.api.editMessageText(chatId, streamMessageId, transportPreview.text);
}
const richRawApi = getTelegramRichRawApi(params.api);
await richRawApi.editMessageText({
chat_id: chatId,
message_id: streamMessageId,
rich_message: preview.richMessage,
});
return true;
}
messageSendAttempted = true;
@@ -340,23 +239,15 @@ export function createTelegramDraftStream(params: {
deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed
? lastRequestedPreview
: renderTelegramDraftPreview(currentText, params.renderText);
const transportPreview = normalizeTelegramDraftTransportPreview(rendered);
const renderedText = transportPreview.text.trimEnd();
const renderedPayloadLength = richMessages
? telegramDraftRichPayloadLength(rendered)
: renderedText.length;
const renderedText = rendered.text.trimEnd();
const renderedPreview = { ...rendered, text: renderedText };
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
const renderedPayloadLength = telegramDraftPreviewPayloadLength(renderedPreview);
if (!renderedText) {
return false;
}
if (renderedPayloadLength > maxChars) {
const chunkLength = findTelegramDraftChunkLength(
currentText,
maxChars,
params.renderText,
richMessages,
);
const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText);
if (!streamState.final) {
if (chunkLength > 0) {
return await sendOrEditStreamMessage(

View File

@@ -226,35 +226,6 @@ const TELEGRAM_ATTR_HTML_TAG_PATTERNS = new Map([
const TELEGRAM_CODE_LANGUAGE_ATTR_PATTERN = /^\s+class="language-[^"]+"\s*$/;
const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20;
const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]);
const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([
"aside",
"audio",
"blockquote",
"details",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"li",
"ol",
"p",
"pre",
"table",
"tg-collage",
"tg-map",
"tg-math-block",
"tg-slideshow",
"tr",
"ul",
"video",
]);
const TELEGRAM_RICH_MEDIA_HTML_TAGS = new Set(["audio", "img", "video"]);
const TELEGRAM_RICH_SIMPLE_HTML_TAGS = new Set([
...TELEGRAM_SIMPLE_HTML_TAGS,
"a",
@@ -718,49 +689,6 @@ export function sanitizeTelegramRichHtml(html: string): string {
);
}
export function limitTelegramRichHtmlNesting(html: string, maxDepth: number): string {
const normalizedMaxDepth = Math.max(1, Math.floor(maxDepth));
const stack: Array<{ name: string; kept: boolean }> = [];
let keptDepth = 0;
let output = "";
let lastIndex = 0;
HTML_TAG_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_TAG_PATTERN.exec(html)) !== null) {
output += html.slice(lastIndex, match.index);
const rawTag = match[0];
const isClosing = match[1] === "</";
const tagName = normalizeLowercaseStringOrEmpty(match[2]);
const isSelfClosing =
!isClosing && (TELEGRAM_VOID_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
if (isClosing) {
const entryIndex = stack.findLastIndex((entry) => entry.name === tagName);
if (entryIndex >= 0) {
const [entry] = stack.splice(entryIndex, 1);
if (entry?.kept) {
keptDepth = Math.max(0, keptDepth - 1);
output += rawTag;
}
}
} else if (isSelfClosing) {
if (tagName === "br" || keptDepth < normalizedMaxDepth) {
output += rawTag;
}
} else {
const kept = keptDepth < normalizedMaxDepth;
stack.push({ name: tagName, kept });
if (kept) {
keptDepth += 1;
output += rawTag;
}
}
lastIndex = HTML_TAG_PATTERN.lastIndex;
}
return output + html.slice(lastIndex);
}
function normalizeTelegramRichMediaBlock(block: string): string {
const normalized = block
.trim()
@@ -997,8 +925,6 @@ type TelegramHtmlTag = {
name: string;
openTag: string;
closeTag: string;
richBlock: boolean;
richMedia: boolean;
};
const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS;
@@ -1019,13 +945,6 @@ function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number {
return tags.reduce((total, tag) => total + tag.closeTag.length, 0);
}
function isTelegramRichBlockHtmlTag(rawTag: string, tagName: string): boolean {
return (
TELEGRAM_RICH_BLOCK_HTML_TAGS.has(tagName) ||
(tagName === "a" && /\sname="[^"]+"/i.test(rawTag))
);
}
function findTelegramHtmlEntityEnd(text: string, start: number): number {
if (text[start] !== "&") {
return -1;
@@ -1099,34 +1018,22 @@ function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
}
}
export function splitTelegramHtmlChunks(
html: string,
limit: number,
options: { blockLimit?: number; mediaLimit?: number } = {},
): string[] {
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
if (!html) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
const blockLimit =
options.blockLimit == null ? undefined : Math.max(1, Math.floor(options.blockLimit));
const mediaLimit =
options.mediaLimit == null ? undefined : Math.max(1, Math.floor(options.mediaLimit));
if (html.length <= normalizedLimit && blockLimit === undefined && mediaLimit === undefined) {
if (html.length <= normalizedLimit) {
return [html];
}
const chunks: string[] = [];
const openTags: TelegramHtmlTag[] = [];
let current = "";
let currentBlockCount = 0;
let currentMediaCount = 0;
let chunkHasPayload = false;
const resetCurrent = () => {
current = buildTelegramHtmlOpenPrefix(openTags);
currentBlockCount = openTags.filter((tag) => tag.richBlock).length;
currentMediaCount = openTags.filter((tag) => tag.richMedia).length;
chunkHasPayload = false;
};
@@ -1189,24 +1096,16 @@ export function splitTelegramHtmlChunks(
const isSelfClosing =
!isClosing &&
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
const isRichBlock = !isClosing && isTelegramRichBlockHtmlTag(rawTag, tagName);
const isRichMedia =
!isClosing &&
(tagName === "figure" ||
(TELEGRAM_RICH_MEDIA_HTML_TAGS.has(tagName) &&
!openTags.some((tag) => tag.name === "figure")));
if (!isClosing) {
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
if (
chunkHasPayload &&
((blockLimit !== undefined && isRichBlock && currentBlockCount >= blockLimit) ||
(mediaLimit !== undefined && isRichMedia && currentMediaCount >= mediaLimit) ||
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit)
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit
) {
flushCurrent();
}
@@ -1216,12 +1115,6 @@ export function splitTelegramHtmlChunks(
if (isSelfClosing) {
chunkHasPayload = true;
}
if (isRichBlock) {
currentBlockCount += 1;
}
if (isRichMedia) {
currentMediaCount += 1;
}
if (isClosing) {
popTelegramHtmlTag(openTags, tagName);
} else if (!isSelfClosing) {
@@ -1229,8 +1122,6 @@ export function splitTelegramHtmlChunks(
name: tagName,
openTag: rawTag,
closeTag: `</${tagName}>`,
richBlock: isRichBlock,
richMedia: isRichMedia,
});
}
lastIndex = tagEnd;

View File

@@ -2,8 +2,11 @@
import type { InlineKeyboardButton, InlineKeyboardMarkup } from "grammy/types";
import type { TelegramInlineButtons } from "./button-types.js";
export type TelegramInlineKeyboardChatType = "direct" | "group" | "unknown";
function toInlineKeyboardButton(
button: TelegramInlineButtons[number][number] | undefined,
chatType: TelegramInlineKeyboardChatType,
): InlineKeyboardButton | undefined {
if (!button?.text) {
return undefined;
@@ -19,6 +22,11 @@ function toInlineKeyboardButton(
: { text: button.text, callback_data: button.callback_data };
}
if (button.web_app?.url) {
if (chatType === "group") {
return button.style
? { text: button.text, url: button.web_app.url, style: button.style }
: { text: button.text, url: button.web_app.url };
}
return button.style
? { text: button.text, web_app: { url: button.web_app.url }, style: button.style }
: { text: button.text, web_app: { url: button.web_app.url } };
@@ -28,14 +36,16 @@ function toInlineKeyboardButton(
export function buildInlineKeyboard(
buttons?: TelegramInlineButtons,
options?: { chatType?: TelegramInlineKeyboardChatType },
): InlineKeyboardMarkup | undefined {
if (!buttons?.length) {
return undefined;
}
const chatType = options?.chatType ?? "unknown";
const rows = buttons
.map((row) =>
row
.map(toInlineKeyboardButton)
.map((button) => toInlineKeyboardButton(button, chatType))
.filter((button): button is InlineKeyboardButton => Boolean(button)),
)
.filter((row) => row.length > 0);

View File

@@ -557,49 +557,6 @@ describe("telegram message cache", () => {
expect(recent.map((entry) => entry.messageId)).toEqual(["42", "43"]);
});
it("preserves rich-message placeholders in subsequent conversation context", async () => {
const cache = createTelegramMessageCache();
const chat = { id: 7, type: "private", first_name: "Nora" } as const;
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 45,
date: 1736380745,
rich_message: { blocks: [{ type: "paragraph" }] },
from: { id: 1, is_bot: false, first_name: "Nora" },
} as Message,
});
await cache.record({
accountId: "default",
chatId: 7,
msg: {
chat,
message_id: 46,
date: 1736380746,
text: "What did I just send?",
from: { id: 1, is_bot: false, first_name: "Nora" },
} as Message,
});
const context = await buildTelegramConversationContext({
cache,
accountId: "default",
chatId: 7,
messageId: "46",
replyChainNodes: [],
recentLimit: 10,
replyTargetWindowSize: 2,
});
expect(context).toHaveLength(1);
expect(context[0]?.node).toMatchObject({
messageId: "45",
body: "[unsupported Telegram rich_message received]",
});
});
it("returns nearby messages around a stale reply target", async () => {
const cache = createTelegramMessageCache();
for (const id of [100, 101, 102, 200, 201]) {

View File

@@ -7,10 +7,7 @@ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveTelegramPrimaryMedia,
resolveTelegramRichMessagePlaceholder,
} from "./bot/body-helpers.js";
import { resolveTelegramPrimaryMedia } from "./bot/body-helpers.js";
import {
buildSenderName,
extractTelegramLocation,
@@ -154,9 +151,7 @@ function resolveMessageBody(msg: Message): string | undefined {
if (location) {
return formatLocationText(location);
}
return (
resolveTelegramRichMessagePlaceholder(msg) ?? resolveTelegramPrimaryMedia(msg)?.placeholder
);
return resolveTelegramPrimaryMedia(msg)?.placeholder;
}
function resolveMediaType(placeholder?: string): string | undefined {

View File

@@ -505,12 +505,11 @@ describe("telegramOutbound", () => {
cfg: {} as never,
to: "12345",
text: "hello",
formatting: { parseMode: "HTML", tableMode: "bullets" },
formatting: { parseMode: "HTML" },
deps: { sendTelegram: sendMessageTelegramMock },
});
const options = lastCallOptions(sendMessageTelegramMock, "12345", "hello");
expect(options.textMode).toBe("html");
expect(options.tableMode).toBe("bullets");
};
const proveMedia = async () => {
sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media", chatId: "12345" });

View File

@@ -13,7 +13,6 @@ import {
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequenceOrFallback,
@@ -21,12 +20,12 @@ import {
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { splitTelegramHtmlChunks } from "./format.js";
import { resolveTelegramInteractiveTextFallback } from "./interactive-fallback.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import { splitTelegramRichTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
import { normalizeTelegramOutboundTarget, parseTelegramTarget } from "./targets.js";
export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000;
export const TELEGRAM_TEXT_CHUNK_LIMIT = TELEGRAM_RICH_TEXT_LIMIT;
export const TELEGRAM_POLL_OPTION_LIMIT = 10;
type TelegramSendFn = typeof import("./send.js").sendMessageTelegram;
@@ -54,9 +53,12 @@ function chunkTelegramOutboundText(
limit: number,
ctx?: { formatting?: OutboundDeliveryFormattingOptions },
): string[] {
return ctx?.formatting?.parseMode === "HTML"
? splitTelegramHtmlChunks(text, limit)
: chunkMarkdownTextWithMode(text, limit, ctx?.formatting?.chunkMode ?? "length");
return splitTelegramRichTextChunks({
text,
textLimit: limit,
textMode: ctx?.formatting?.parseMode === "HTML" ? "html" : "markdown",
chunkMode: ctx?.formatting?.chunkMode ?? "length",
});
}
async function resolveTelegramSendContext(params: {
@@ -75,7 +77,6 @@ async function resolveTelegramSendContext(params: {
cfg: NonNullable<TelegramSendOpts>["cfg"];
verbose: false;
textMode?: "html";
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
messageThreadId?: number;
replyToMessageId?: number;
accountId?: string;
@@ -95,7 +96,6 @@ async function resolveTelegramSendContext(params: {
silent: params.silent,
gatewayClientScopes: params.gatewayClientScopes,
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
tableMode: params.formatting?.tableMode,
},
};
}
@@ -251,7 +251,9 @@ export function createTelegramOutboundAdapter(
});
},
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
typeof fallbackLimit === "number"
? Math.min(fallbackLimit, TELEGRAM_RICH_TEXT_LIMIT)
: TELEGRAM_RICH_TEXT_LIMIT,
pollMaxOptions: TELEGRAM_POLL_OPTION_LIMIT,
supportsPollDurationSeconds: true,
supportsAnonymousPolls: true,

View File

@@ -11,8 +11,6 @@ import type {
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import {
escapeTelegramHtml,
limitTelegramRichHtmlNesting,
markdownToTelegramRichHtml,
sanitizeTelegramRichHtml,
splitTelegramHtmlChunks,
@@ -27,8 +25,6 @@ type TelegramRichMessageReplyMarkup =
export const TELEGRAM_RICH_TEXT_LIMIT = 32_768;
export const TELEGRAM_RICH_BLOCK_LIMIT = 500;
export const TELEGRAM_RICH_MEDIA_LIMIT = 50;
export const TELEGRAM_RICH_NESTING_LIMIT = 16;
export type TelegramInputRichMessage =
| {
@@ -53,7 +49,7 @@ export type TelegramRichTextMode = "markdown" | "html";
export type TelegramRichTextChunk = {
text: string;
textMode: "html";
textMode: TelegramRichTextMode;
plainText: string;
};
@@ -170,7 +166,7 @@ export function buildTelegramRichHtml(
html: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const safeHtml = prepareTelegramRichHtml(html);
const safeHtml = sanitizeTelegramRichHtml(html);
return options?.skipEntityDetection === true
? { html: safeHtml, skip_entity_detection: true }
: { html: safeHtml };
@@ -186,59 +182,6 @@ export function buildTelegramRichMessage(
: buildTelegramRichMarkdown(text, options);
}
function prepareTelegramRichHtml(html: string): string {
return limitTelegramRichHtmlNesting(sanitizeTelegramRichHtml(html), TELEGRAM_RICH_NESTING_LIMIT);
}
const TELEGRAM_RICH_HTML_CHUNK_LIMITS = {
blockLimit: TELEGRAM_RICH_BLOCK_LIMIT,
mediaLimit: TELEGRAM_RICH_MEDIA_LIMIT,
} as const;
function splitPreparedTelegramRichHtml(params: {
html: string;
sourceFallback: string;
textLimit: number;
}): string[] {
try {
const chunks = splitTelegramHtmlChunks(
params.html,
params.textLimit,
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
);
if (chunks.length > 0) {
return chunks;
}
} catch {
// Fall through to readable source text when rich planning cannot preserve the payload.
}
return splitTelegramHtmlChunks(escapeTelegramHtml(params.sourceFallback), params.textLimit);
}
export function isTelegramRichMessageWithinStructuralLimits(
message: TelegramInputRichMessage,
): boolean {
if (message.markdown !== undefined) {
if (splitTelegramRichMarkdownBlocks(message.markdown, TELEGRAM_RICH_BLOCK_LIMIT).length > 1) {
return false;
}
return (
splitTelegramHtmlChunks(
prepareTelegramRichHtml(markdownToTelegramRichHtml(message.markdown)),
TELEGRAM_RICH_TEXT_LIMIT,
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
).length <= 1
);
}
return (
splitTelegramHtmlChunks(
prepareTelegramRichHtml(message.html),
TELEGRAM_RICH_TEXT_LIMIT,
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
).length <= 1
);
}
type RichMarkdownFenceSpan = {
start: number;
end: number;
@@ -409,11 +352,7 @@ export function splitTelegramRichTextChunks(params: {
chunkMode: ChunkMode;
}): string[] {
return params.textMode === "html"
? splitTelegramHtmlChunks(
prepareTelegramRichHtml(params.text),
params.textLimit,
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
)
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode);
}
@@ -426,26 +365,15 @@ export function splitTelegramRichMessageTextChunks(params: {
skipEntityDetection?: boolean;
}): TelegramRichTextChunk[] {
const renderMarkdownChunk = (chunk: string) =>
prepareTelegramRichHtml(
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
}),
);
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
const htmlChunks =
params.textMode === "html"
? splitPreparedTelegramRichHtml({
html: prepareTelegramRichHtml(params.text),
sourceFallback: params.text,
textLimit: params.textLimit,
})
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).flatMap(
(chunk) =>
splitPreparedTelegramRichHtml({
html: renderMarkdownChunk(chunk),
sourceFallback: chunk,
textLimit: params.textLimit,
}),
(chunk) => splitTelegramHtmlChunks(renderMarkdownChunk(chunk), params.textLimit),
);
return htmlChunks.map((chunk) => ({
text: chunk,

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ import * as grammy from "grammy";
import { type ApiClientOptions, Bot, HttpError } from "grammy";
import type { ReactionType, ReactionTypeEmoji } from "grammy/types";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
@@ -31,6 +30,8 @@ import { buildInlineKeyboard } from "./inline-keyboard.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramMessageHasNoTextError,
isTelegramMessageNotModifiedError,
isTelegramRateLimitError,
isTelegramServerError,
} from "./network-errors.js";
@@ -47,7 +48,6 @@ import {
TELEGRAM_RICH_TEXT_LIMIT,
toTelegramRichMessageContextParams,
type TelegramEditRichMessageTextParams,
type TelegramRichMessageContextParams,
type TelegramRichTextChunk,
} from "./rich-message.js";
import {
@@ -77,9 +77,7 @@ export { buildInlineKeyboard } from "./inline-keyboard.js";
type TelegramApi = Bot["api"];
export type TelegramApiOverride = Partial<TelegramApi>;
type TelegramSendMessageParams = Parameters<TelegramApi["sendMessage"]>[2];
type TelegramSendPollParams = Parameters<TelegramApi["sendPoll"]>[3];
type TelegramEditMessageTextParams = Parameters<TelegramApi["editMessageText"]>[3];
type TelegramEditMessageCaptionParams = Parameters<TelegramApi["editMessageCaption"]>[2];
type TelegramCreateForumTopicParams = NonNullable<Parameters<TelegramApi["createForumTopic"]>[2]>;
type TelegramThreadScopedParams = {
@@ -102,7 +100,6 @@ type TelegramSendOpts = {
api?: TelegramApiOverride;
retry?: RetryConfig;
textMode?: "markdown" | "html";
tableMode?: MarkdownTableMode;
/** Send audio as voice message instead of audio file. Defaults to false. */
asVoice?: boolean;
/** Send video as video note instead of regular video. Defaults to false. */
@@ -179,42 +176,6 @@ function resolveTelegramMessageIdOrThrow(
throw new Error(`Telegram ${context} returned no message_id`);
}
function splitTelegramPlainTextChunks(text: string, limit: number): string[] {
if (!text) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
const chunks: string[] = [];
for (let start = 0; start < text.length; start += normalizedLimit) {
chunks.push(text.slice(start, start + normalizedLimit));
}
return chunks;
}
function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] {
if (!text) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit);
if (chunkCount <= 1 || fixedChunks.length >= chunkCount) {
return fixedChunks;
}
const chunks: string[] = [];
let offset = 0;
for (let index = 0; index < chunkCount; index += 1) {
const remainingChars = text.length - offset;
const remainingChunks = chunkCount - index;
const nextChunkLength =
remainingChunks === 1
? remainingChars
: Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks));
chunks.push(text.slice(offset, offset + nextChunkLength));
offset += nextChunkLength;
}
return chunks;
}
function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): void {
const parts = [
"telegram outbound send ok",
@@ -242,12 +203,10 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo
}
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const MESSAGE_NOT_MODIFIED_RE =
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const MESSAGE_HAS_NO_TEXT_RE = /400:\s*Bad Request:\s*there is no text in the message to edit/i;
const MESSAGE_DELETE_NOOP_RE =
/message to delete not found|message can't be deleted|MESSAGE_ID_INVALID|MESSAGE_DELETE_FORBIDDEN/i;
const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i;
const TELEGRAM_LEGACY_TEXT_LIMIT = 4096;
const sendLogger = createSubsystemLogger("telegram/send");
const diagLogger = createSubsystemLogger("telegram/diagnostic");
const telegramClientOptionsCache = new Map<string, ApiClientOptions | undefined>();
@@ -434,14 +393,6 @@ function normalizeMessageId(raw: string | number): number {
throw new Error("Message id is required for Telegram actions");
}
function isTelegramMessageNotModifiedError(err: unknown): boolean {
return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err));
}
function isTelegramMessageHasNoTextError(err: unknown): boolean {
return MESSAGE_HAS_NO_TEXT_RE.test(formatErrorMessage(err));
}
function isTelegramMessageDeleteNoopError(err: unknown): boolean {
return MESSAGE_DELETE_NOOP_RE.test(formatErrorMessage(err));
}
@@ -626,13 +577,14 @@ export async function sendMessageTelegram(
const mediaMaxBytes =
opts.maxBytes ??
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
const replyMarkup = buildInlineKeyboard(opts.buttons);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const replyMarkup = buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType });
const threadParams = buildTelegramThreadReplyParams({
thread: resolveTelegramSendThreadSpec({
targetMessageThreadId: target.messageThreadId,
messageThreadId: opts.messageThreadId,
chatType: target.chatType,
chatType: resolvedChatType,
}),
replyToMessageId: opts.replyToMessageId,
replyQuoteText: opts.quoteText,
@@ -654,98 +606,125 @@ export async function sendMessageTelegram(
});
const textMode = opts.textMode ?? "markdown";
const useRichMessages = account.config.richMessages === true;
const tableMode =
opts.tableMode ??
resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
supportsBlockTables: useRichMessages,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
supportsBlockTables: true,
});
const richMessageOptions = {
skipEntityDetection: account.config.linkPreview === false,
tableMode,
};
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
// Resolve link preview setting from config (default: enabled).
const linkPreviewEnabled = account.config.linkPreview ?? true;
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const useRichTextSend = resolvedChatType !== "group";
const textTransportLimit = useRichTextSend
? TELEGRAM_RICH_TEXT_LIMIT
: TELEGRAM_LEGACY_TEXT_LIMIT;
const textLimit = Math.min(
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
fallbackLimit: textTransportLimit,
}),
textTransportLimit,
);
const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId);
type TelegramTextChunk = {
plainText: string;
htmlText?: string;
type TelegramTextParams = Record<string, unknown> & {
message_thread_id?: number;
reply_markup?: ReturnType<typeof buildInlineKeyboard>;
};
const sendTelegramTextChunk = async (
chunk: TelegramTextChunk,
params?: TelegramSendMessageParams,
chunk: TelegramRichTextChunk,
params?: TelegramTextParams,
) => {
const baseParams = params ? { ...params } : {};
if (linkPreviewOptions) {
baseParams.link_preview_options = linkPreviewOptions;
}
const plainParams: TelegramSendMessageParams = {
...baseParams,
...(opts.silent === true ? { disable_notification: true } : {}),
};
const hasPlainParams = Object.keys(plainParams).length > 0;
const requestPlain = (label: string) =>
requestWithChatNotFound(
if (useRichTextSend) {
const richRawApi = getTelegramRichRawApi(api);
const richParams = {
...params,
...(opts.silent === true ? { disable_notification: true } : {}),
};
const result = await requestWithChatNotFound(
() =>
hasPlainParams
? api.sendMessage(chatId, chunk.plainText, plainParams)
: api.sendMessage(chatId, chunk.plainText),
label,
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
...richParams,
}),
"richMessage",
);
const result = !chunk.htmlText
? await requestPlain("message")
: await withTelegramHtmlParseFallback({
label: "message",
verbose: opts.verbose,
requestHtml: (label) =>
requestWithChatNotFound(
() =>
api.sendMessage(chatId, chunk.htmlText ?? chunk.plainText, {
parse_mode: "HTML" as const,
...plainParams,
}),
label,
),
requestPlain,
});
return { result, acceptedParams: params };
return { result, acceptedParams: params, operation: "sendRichMessage" };
}
const legacyParams: Record<string, unknown> = {
parse_mode: "HTML",
...threadParams,
...(opts.silent === true ? { disable_notification: true } : {}),
...(params?.reply_markup ? { reply_markup: params.reply_markup } : {}),
};
if (account.config.linkPreview === false) {
legacyParams.link_preview_options = { is_disabled: true };
}
const plainParams = { ...legacyParams };
delete plainParams.parse_mode;
const result = await withTelegramHtmlParseFallback({
label: "sendMessage",
verbose: opts.verbose,
requestHtml: (label) =>
requestWithChatNotFound(
() =>
api.sendMessage(
chatId,
chunk.text,
legacyParams as Parameters<TelegramApi["sendMessage"]>[2],
) as Promise<TelegramMessageLike>,
label,
),
requestPlain: (label) =>
requestWithChatNotFound(
() =>
api.sendMessage(
chatId,
telegramHtmlToPlainTextFallback(chunk.text),
plainParams as Parameters<TelegramApi["sendMessage"]>[2],
) as Promise<TelegramMessageLike>,
label,
),
});
return { result, acceptedParams: threadParams, operation: "sendMessage" };
};
const buildTextParams = (isLastChunk: boolean) =>
hasThreadParams || (isLastChunk && replyMarkup)
? {
...threadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
const buildRichTextParams = (isLastChunk: boolean) =>
hasRichThreadParams || (isLastChunk && replyMarkup)
? {
...richThreadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined;
useRichTextSend
? hasRichThreadParams || (isLastChunk && replyMarkup)
? {
...richThreadParams,
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
}
: undefined
: isLastChunk && replyMarkup
? { reply_markup: replyMarkup }
: undefined;
const sendTelegramTextChunks = async (
chunks: TelegramTextChunk[],
chunks: TelegramRichTextChunk[],
context: string,
): Promise<{ messageId: string; chatId: string }> => {
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramThreadScopedParams | undefined;
let lastOperation = "";
let sentChunkCount = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (!chunk) {
continue;
}
const { result: res, acceptedParams } = await sendTelegramTextChunk(
chunk,
buildTextParams(index === chunks.length - 1),
);
const {
result: res,
acceptedParams,
operation,
} = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1));
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
await recordOutboundMessageForPromptContext({
@@ -762,6 +741,7 @@ export async function sendMessageTelegram(
lastMessageId = String(messageId);
lastChatId = String(res?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
lastOperation = operation;
sentChunkCount += 1;
}
if (lastMessageId) {
@@ -769,7 +749,7 @@ export async function sendMessageTelegram(
accountId: account.accountId,
chatId: lastChatId,
messageId: lastMessageId,
operation: "sendMessage",
operation: lastOperation,
deliveryKind: "text",
messageThreadId: lastAcceptedParams?.message_thread_id,
replyToMessageId: opts.replyToMessageId,
@@ -780,117 +760,26 @@ export async function sendMessageTelegram(
return { messageId: lastMessageId, chatId: lastChatId };
};
const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => {
const htmlText = renderHtmlText(rawText);
const fallbackText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : rawText;
let htmlChunks: string[];
try {
htmlChunks = splitTelegramHtmlChunks(htmlText, 4000);
} catch (error) {
logVerbose(
`telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage(
error,
)}`,
);
return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText }));
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
if (!useRichTextSend) {
return splitTelegramHtmlChunks(renderHtmlText(rawText), textLimit).map((chunkText) => ({
text: chunkText,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(chunkText),
}));
}
const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000);
if (fixedPlainTextChunks.length > htmlChunks.length) {
logVerbose(
`telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`,
);
return fixedPlainTextChunks.map((plainText) => ({ plainText }));
}
const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000);
return htmlChunks.map((htmlTextLocal, index) => ({
htmlText: htmlTextLocal,
plainText: plainTextChunks[index] ?? htmlTextLocal,
}));
};
const sendChunkedText = async (rawText: string, context: string) =>
useRichMessages
? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context)
: await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => {
const textLimit = Math.min(
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
}),
TELEGRAM_RICH_TEXT_LIMIT,
);
return splitTelegramRichMessageTextChunks({
text: rawText,
textLimit,
textMode,
chunkMode: resolveChunkMode(cfg, "telegram", account.accountId),
chunkMode,
tableMode,
skipEntityDetection: account.config.linkPreview === false,
skipEntityDetection: richMessageOptions.skipEntityDetection,
});
};
const sendTelegramRichTextChunks = async (
chunks: TelegramRichTextChunk[],
context: string,
): Promise<{ messageId: string; chatId: string }> => {
const richRawApi = getTelegramRichRawApi(api);
let lastMessageId = "";
let lastChatId = chatId;
let lastAcceptedParams: TelegramRichMessageContextParams | undefined;
let sentChunkCount = 0;
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
if (!chunk) {
continue;
}
const acceptedParams = buildRichTextParams(index === chunks.length - 1);
const result = await requestWithChatNotFound(
() =>
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, {
skipEntityDetection: account.config.linkPreview === false,
tableMode,
}),
...acceptedParams,
...(opts.silent === true ? { disable_notification: true } : {}),
}),
"richMessage",
);
const messageId = resolveTelegramMessageIdOrThrow(result, context);
recordSentMessage(chatId, messageId, cfg);
await recordOutboundMessageForPromptContext({
cfg,
account,
chatId,
message: result,
messageId,
text: chunk.plainText,
...(acceptedParams?.message_thread_id !== undefined
? { messageThreadId: acceptedParams.message_thread_id }
: {}),
});
lastMessageId = String(messageId);
lastChatId = String(result?.chat?.id ?? chatId);
lastAcceptedParams = acceptedParams;
sentChunkCount += 1;
}
if (lastMessageId) {
logTelegramOutboundSendOk({
accountId: account.accountId,
chatId: lastChatId,
messageId: lastMessageId,
operation: "sendRichMessage",
deliveryKind: "text",
messageThreadId: lastAcceptedParams?.message_thread_id,
replyToMessageId: opts.replyToMessageId,
silent: opts.silent,
chunkCount: sentChunkCount,
});
}
return { messageId: lastMessageId, chatId: lastChatId };
};
const sendChunkedText = async (rawText: string, context: string) =>
await sendTelegramTextChunks(buildChunkedTextPlan(rawText), context);
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
try {
@@ -1468,13 +1357,16 @@ export async function editMessageReplyMarkupTelegram(
gatewayClientScopes: opts.gatewayClientScopes,
});
const messageId = normalizeMessageId(messageIdInput);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
});
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
const replyMarkup = buildInlineKeyboard(buttons, { chatType: resolvedChatType }) ?? {
inline_keyboard: [],
};
try {
await requestWithDiag(
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
@@ -1512,6 +1404,7 @@ export async function editMessageTelegram(
gatewayClientScopes: opts.gatewayClientScopes,
});
const messageId = normalizeMessageId(messageIdInput);
const resolvedChatType = parseTelegramTarget(chatId).chatType;
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
@@ -1528,47 +1421,34 @@ export async function editMessageTelegram(
) => requestWithDiag(fn, label, shouldLog ? { shouldLog } : undefined);
const textMode = opts.textMode ?? "markdown";
const useRichMessages = account.config.richMessages === true;
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
supportsBlockTables: useRichMessages,
supportsBlockTables: true,
});
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text;
const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined;
const richMessage = useRichMessages
? buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode,
})
: undefined;
const richRawApi = getTelegramRichRawApi(api);
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode,
});
// Reply markup semantics:
// - buttons === undefined → don't send reply_markup (keep existing)
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
// - otherwise → send built inline keyboard
const shouldTouchButtons = opts.buttons !== undefined;
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
const builtKeyboard = shouldTouchButtons
? buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType })
: undefined;
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
const textEditParams: TelegramEditMessageTextParams = {
parse_mode: "HTML",
};
if (opts.linkPreview === false) {
textEditParams.link_preview_options = { is_disabled: true };
}
const textEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> = {};
if (replyMarkup !== undefined) {
textEditParams.reply_markup = replyMarkup;
}
const plainTextParams: TelegramEditMessageTextParams = {};
if (opts.linkPreview === false) {
plainTextParams.link_preview_options = { is_disabled: true };
}
if (replyMarkup !== undefined) {
plainTextParams.reply_markup = replyMarkup;
}
const captionEditParams: TelegramEditMessageCaptionParams = {
caption: htmlText,
parse_mode: "HTML",
@@ -1583,42 +1463,18 @@ export async function editMessageTelegram(
plainCaptionParams.reply_markup = replyMarkup;
}
const performTextEdit = () => {
if (richRawApi && richMessage) {
const richEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> =
replyMarkup === undefined ? {} : { reply_markup: replyMarkup };
return requestWithEditShouldLog(
() =>
richRawApi.editMessageText({
chat_id: chatId,
message_id: messageId,
rich_message: richMessage,
...richEditParams,
}),
"editMessage",
(err) => !isTelegramMessageNotModifiedError(err),
);
}
return withTelegramHtmlParseFallback({
label: "editMessage",
verbose: opts.verbose,
requestHtml: (retryLabel) =>
requestWithEditShouldLog(
() => api.editMessageText(chatId, messageId, htmlText, textEditParams),
retryLabel,
(err) => !isTelegramMessageNotModifiedError(err),
),
requestPlain: (retryLabel) =>
requestWithEditShouldLog(
() =>
Object.keys(plainTextParams).length > 0
? api.editMessageText(chatId, messageId, plainText, plainTextParams)
: api.editMessageText(chatId, messageId, plainText),
retryLabel,
(plainErr) => !isTelegramMessageNotModifiedError(plainErr),
),
});
};
const performTextEdit = () =>
requestWithEditShouldLog(
() =>
richRawApi.editMessageText({
chat_id: chatId,
message_id: messageId,
rich_message: richMessage,
...textEditParams,
}),
"editMessage",
(err) => !isTelegramMessageNotModifiedError(err),
);
const performCaptionEdit = () =>
withTelegramHtmlParseFallback({

View File

@@ -1,5 +1,5 @@
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
// Telegram tests cover telegram outbound plugin behavior.
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
import { describe, expect, it } from "vitest";
import { splitTelegramHtmlChunks } from "./format.js";
import { telegramOutbound } from "./outbound-adapter.js";
@@ -19,13 +19,13 @@ describe("telegramPlugin outbound", () => {
it("uses static outbound contract when Telegram runtime is uninitialized", () => {
clearTelegramRuntime();
const text = `${"hello\n".repeat(1200)}tail`;
const expected = chunkMarkdownTextWithMode(text, 4000, "length");
const expected = chunkMarkdownTextWithMode(text, 32_768, "length");
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected);
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
expect(telegramOutbound.deliveryMode).toBe("direct");
expect(telegramOutbound.chunkerMode).toBe("markdown");
expect(telegramOutbound.chunkedTextFormatting).toBeUndefined();
expect(telegramOutbound.textChunkLimit).toBe(4000);
expect(telegramOutbound.textChunkLimit).toBe(32_768);
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
"markdown",
);
@@ -43,7 +43,7 @@ describe("telegramPlugin outbound", () => {
expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]);
});
it("preserves markdown tables for the configured delivery renderer", () => {
it("keeps markdown tables intact for rich message parsing", () => {
clearTelegramRuntime();
const text = ["| Name | Value |", "|------|-------|", "| A | 1 |"].join("\n");
@@ -54,66 +54,63 @@ describe("telegramPlugin outbound", () => {
expect(chunks).toEqual([text]);
});
it("keeps wide markdown tables as visible text in the HTML text path", () => {
it("keeps wide markdown tables for rich HTML rendering", () => {
clearTelegramRuntime();
const text = markdownTable(21);
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect(chunks).toHaveLength(1);
expect(chunks?.[0]).toContain("| H21 |");
expect(chunks?.[0]).toContain("| 1 | 2 | 3 |");
expect(chunks).toEqual([text]);
});
it("preserves both fenced and unfenced wide tables as visible text", () => {
it("keeps fenced and unfenced wide markdown tables for rich HTML rendering", () => {
clearTelegramRuntime();
const fencedTable = markdownTable(25);
const outsideTable = markdownTable(21);
const text = ["Before", "~~~", fencedTable, "~~~", "After", outsideTable].join("\n");
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect(chunks).toHaveLength(1);
expect(chunks?.[0]).toContain("Before");
expect(chunks?.[0]).toContain("After");
expect(chunks?.[0]).toContain(fencedTable);
expect(chunks?.[0]).toContain(outsideTable);
expect(chunks).toEqual([text]);
});
it("chunks long markdown paragraphs by the Telegram text-message limit", () => {
it("chunks rich markdown by Telegram's block limit", () => {
clearTelegramRuntime();
const text = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join("\n\n");
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect((chunks?.length ?? 0) > 1).toBe(true);
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(chunks?.join("")).toContain("Paragraph 900");
expect(chunks).toHaveLength(2);
expect(
chunks?.every(
(chunk) => chunk.split(/\n[\t ]*\n+/).filter((block) => block.trim()).length <= 500,
),
).toBe(true);
expect(chunks?.join("\n\n")).toBe(text);
});
it("chunks long markdown headings by the Telegram text-message limit", () => {
it("chunks rich markdown headings by Telegram's block limit", () => {
clearTelegramRuntime();
const text = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect((chunks?.length ?? 0) > 1).toBe(true);
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(chunks?.join("")).toContain("Heading 600");
expect(chunks).toHaveLength(2);
expect(chunks?.at(0)?.match(/^# /gm)).toHaveLength(500);
expect(chunks?.at(1)?.match(/^# /gm)).toHaveLength(100);
expect(chunks?.join("\n")).toBe(text);
});
it("chunks long markdown lists by the Telegram text-message limit", () => {
it("keeps long rich markdown lists intact", () => {
clearTelegramRuntime();
const text = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n");
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect((chunks?.length ?? 0) > 1).toBe(true);
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(chunks?.join("")).toContain("Item 600");
expect(chunks).toEqual([text]);
});
it("chunks tall markdown tables by the Telegram text-message limit", () => {
it("keeps tall rich markdown tables intact", () => {
clearTelegramRuntime();
const text = [
"| Name | Value |",
@@ -121,10 +118,8 @@ describe("telegramPlugin outbound", () => {
...Array.from({ length: 600 }, (_, index) => `| Row ${index + 1} | ${index + 1} |`),
].join("\n");
const chunks = telegramOutbound.chunker?.(text, 4000);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect((chunks?.length ?? 0) > 1).toBe(true);
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
expect(chunks?.join("")).toContain("Row 600");
expect(chunks).toEqual([text]);
});
});

View File

@@ -26,12 +26,12 @@ import {
setRuntimeConfigSourceSnapshotMock,
startWebAutoReplyMonitor,
} from "./auto-reply.test-harness.js";
import { waitForWaConnection } from "./session.js";
import {
createTestLegacyFlatWebInboundMessage,
createTestWebInboundMessage,
} from "./inbound/test-message.test-helper.js";
import type { WebInboundMessageInput } from "./inbound/types.js";
import { waitForWaConnection } from "./session.js";
type DrainSelectionEntry = {
channel: string;
@@ -127,6 +127,16 @@ function mockCallArg(mocked: unknown, callIndex: number, argIndex: number): unkn
return call[argIndex];
}
async function expectPathMissing(targetPath: string): Promise<void> {
try {
await fs.stat(targetPath);
} catch (error) {
expect((error as { code?: unknown }).code).toBe("ENOENT");
return;
}
throw new Error(`Expected path to be missing: ${targetPath}`);
}
describe("web auto-reply connection", () => {
installWebAutoReplyUnitTestHooks();
@@ -407,7 +417,6 @@ describe("web auto-reply connection", () => {
expect(sleep).not.toHaveBeenCalled();
expectErrorContaining(runtime.error, "status 440");
expectErrorContaining(runtime.error, "session conflict");
expectErrorContaining(runtime.error, "openclaw channels logout --channel whatsapp");
expectErrorContaining(runtime.error, "Stopping web monitoring");
});
@@ -425,14 +434,15 @@ describe("web auto-reply connection", () => {
error: "Stream Errored (logged out)",
},
] as const)(
"stops active listener and preserves auth after terminal status $status",
"clears stale auth and active listener after terminal status $status",
async ({ status, isLoggedOut, healthState, error }) => {
const accountId = `terminal-${status}`;
const authDir = path.join(resolveOAuthDir(), "whatsapp", accountId);
const credsPath = resolveWebCredsPath(authDir);
const credsJson = JSON.stringify({ me: { id: "123@s.whatsapp.net" } });
await fs.mkdir(authDir, { recursive: true });
await fs.writeFile(credsPath, credsJson);
await fs.writeFile(
resolveWebCredsPath(authDir),
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
);
setLoadConfigMock({
channels: {
whatsapp: {
@@ -479,7 +489,7 @@ describe("web auto-reply connection", () => {
expect(scripted.getListenerCount()).toBe(1);
expect(sleep).not.toHaveBeenCalled();
expect(getActiveWebListener(accountId)).toBeNull();
await expect(fs.readFile(credsPath, "utf8")).resolves.toBe(credsJson);
await expectPathMissing(authDir);
expect(
statuses.filter((entry) => entry.connected === false && entry.healthState === healthState),
).not.toEqual([]);

View File

@@ -35,7 +35,7 @@ import {
resolveReconnectPolicy,
sleepWithAbort,
} from "../reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js";
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
@@ -142,6 +142,43 @@ function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnst
);
}
async function clearTerminalWebAuthState(params: {
account: ReturnType<typeof resolveWhatsAppAccount>;
runtime: RuntimeEnv;
statusLabel: number | "unknown";
healthState: "logged-out" | "conflict";
log: ReturnType<typeof getChildLogger>;
}) {
try {
const cleared = await logoutWeb({
authDir: params.account.authDir,
isLegacyAuthDir: params.account.isLegacyAuthDir,
runtime: params.runtime,
});
params.log.warn(
{
accountId: params.account.accountId,
cleared,
healthState: params.healthState,
status: params.statusLabel,
},
"web reconnect: cleared cached auth after terminal close",
);
} catch (error) {
params.log.warn(
{
accountId: params.account.accountId,
error: formatError(error),
healthState: params.healthState,
status: params.statusLabel,
},
"web reconnect: failed clearing cached auth after terminal close",
);
params.runtime.error(
`WhatsApp Web cleanup failed after terminal close (status ${params.statusLabel}). Run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\`, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`.`,
);
}
}
const DEFAULT_TRANSPORT_TIMEOUT_MS = 5 * 60 * 1000;
export async function monitorWebChannel(
@@ -394,12 +431,26 @@ export async function monitorWebChannel(
"web reconnect: setup status error; max attempts reached",
);
if (setupDecision.healthState === "logged-out") {
await clearTerminalWebAuthState({
account,
runtime,
statusLabel: setupDecision.normalized.statusLabel,
healthState: setupDecision.healthState,
log: reconnectLogger,
});
runtime.error(
`WhatsApp session logged out during setup. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (setupDecision.healthState === "conflict") {
await clearTerminalWebAuthState({
account,
runtime,
statusLabel: setupDecision.normalized.statusLabel,
healthState: setupDecision.healthState,
log: reconnectLogger,
});
runtime.error(
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
`WhatsApp Web connection closed during setup (status ${setupDecision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
runtime.error(
@@ -617,10 +668,24 @@ export async function monitorWebChannel(
});
if (decision.healthState === "logged-out") {
await clearTerminalWebAuthState({
account,
runtime,
statusLabel: decision.normalized.statusLabel,
healthState: decision.healthState,
log: reconnectLogger,
});
runtime.error(
`WhatsApp session logged out. Run \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` to relink.`,
);
} else if (decision.healthState === "conflict") {
await clearTerminalWebAuthState({
account,
runtime,
statusLabel: decision.normalized.statusLabel,
healthState: decision.healthState,
log: reconnectLogger,
});
reconnectLogger.warn(
{
connectionId: connection.connectionId,
@@ -630,7 +695,7 @@ export async function monitorWebChannel(
"web reconnect: non-retryable close status; stopping monitor",
);
runtime.error(
`WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then restart the channel. To force a fresh QR, run \`${formatCliCommand("openclaw channels logout --channel whatsapp")}\` before \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
`WhatsApp Web connection closed (status ${decision.normalized.statusLabel}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\`. Stopping web monitoring.`,
);
} else {
reconnectLogger.warn(

View File

@@ -13,12 +13,7 @@ import {
} from "./connection-controller.js";
import { enqueueCredsSave, writeCredsJsonAtomically } from "./creds-persistence.js";
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
import {
createWaSocket,
logoutWeb,
readWebAuthExistsForDecision,
waitForWaConnection,
} from "./session.js";
import { createWaSocket, readWebAuthExistsForDecision, waitForWaConnection } from "./session.js";
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
vi.mock("./session.js", async () => {
@@ -27,14 +22,12 @@ vi.mock("./session.js", async () => {
...actual,
createWaSocket: vi.fn(),
waitForWaConnection: vi.fn(),
logoutWeb: vi.fn(async () => true),
readWebAuthExistsForDecision: vi.fn(async () => ({ outcome: "stable" as const, exists: true })),
};
});
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const logoutWebMock = vi.mocked(logoutWeb);
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
function createListenerStub(messageId = "ok") {
@@ -55,78 +48,11 @@ function createSocketWithTransportEmitter() {
};
}
const loginAuthDir = "/tmp/wa-auth";
function loggedOutError() {
return { output: { statusCode: DisconnectReason.loggedOut } };
}
function createLoginResultHarness() {
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const runtime = { log: vi.fn() } as never;
return {
initialSock,
replacementSock,
runtime,
run: (opts: {
waitForConnection: ReturnType<typeof vi.fn>;
createSocket: ReturnType<typeof vi.fn>;
verbose?: boolean;
socketTiming?: {
connectTimeoutMs: number;
defaultQueryTimeoutMs: number;
keepAliveIntervalMs: number;
};
onQr?: (qr: string) => void;
onSocketReplaced?: (sock: unknown) => void;
}) =>
waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: loginAuthDir,
isLegacyAuthDir: false,
verbose: opts.verbose ?? false,
runtime,
waitForConnection: opts.waitForConnection as never,
createSocket: opts.createSocket as never,
...(opts.socketTiming ? { socketTiming: opts.socketTiming } : {}),
...(opts.onQr ? { onQr: opts.onQr } : {}),
...(opts.onSocketReplaced ? { onSocketReplaced: opts.onSocketReplaced } : {}),
}),
};
}
async function runLoggedOutRecovery(opts: {
cleanupCleared?: boolean;
authDecisions?: Array<{ outcome: "stable"; exists: boolean }>;
secondWait?: "resolve" | "logged-out";
}) {
if (opts.cleanupCleared === false) {
logoutWebMock.mockResolvedValueOnce(false);
}
for (const decision of opts.authDecisions ?? []) {
readWebAuthExistsForDecisionMock.mockResolvedValueOnce(decision);
}
const harness = createLoginResultHarness();
const error = loggedOutError();
const waitForConnection = vi.fn().mockRejectedValueOnce(error);
if (opts.secondWait === "resolve") {
waitForConnection.mockResolvedValueOnce(undefined);
} else if (opts.secondWait === "logged-out") {
waitForConnection.mockRejectedValueOnce(error);
}
const createSocket = vi.fn(async () => harness.replacementSock);
const result = await harness.run({ waitForConnection, createSocket });
return { createSocket, error, harness, result, waitForConnection };
}
describe("WhatsAppConnectionController", () => {
let controller: WhatsAppConnectionController;
beforeEach(() => {
vi.clearAllMocks();
logoutWebMock.mockResolvedValue(true);
readWebAuthExistsForDecisionMock
.mockReset()
.mockResolvedValue({ outcome: "stable", exists: true });
@@ -212,7 +138,8 @@ describe("WhatsAppConnectionController", () => {
});
it("restarts login once on status 408 and preserves replacement socket options", async () => {
const harness = createLoginResultHarness();
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const waitForConnection = vi
.fn()
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
@@ -222,14 +149,18 @@ describe("WhatsAppConnectionController", () => {
const createSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
opts?.onQr?.("qr-after-timeout");
return harness.replacementSock;
return replacementSock;
},
);
const result = await harness.run({
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: true,
waitForConnection,
createSocket,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
socketTiming: {
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
@@ -242,28 +173,24 @@ describe("WhatsAppConnectionController", () => {
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: harness.replacementSock,
sock: replacementSock,
});
expect(harness.initialSock.end).toHaveBeenCalledOnce();
expect(initialSock.end).toHaveBeenCalledOnce();
expect(createSocket).toHaveBeenCalledWith(false, true, {
authDir: loginAuthDir,
authDir: "/tmp/wa-auth",
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
keepAliveIntervalMs: 30_000,
onQr,
});
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
expect(onSocketReplaced).toHaveBeenCalledWith(harness.replacementSock);
expect(waitForConnection).toHaveBeenNthCalledWith(1, harness.initialSock, {
timeout: "none",
});
expect(waitForConnection).toHaveBeenNthCalledWith(2, harness.replacementSock, {
timeout: "none",
});
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
expect(waitForConnection).toHaveBeenNthCalledWith(2, replacementSock, { timeout: "none" });
});
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
const harness = createLoginResultHarness();
const initialSock = createSocketWithTransportEmitter();
const afterTimeoutSock = createSocketWithTransportEmitter();
const afterPairingRestartSock = createSocketWithTransportEmitter();
const waitForConnection = vi
@@ -276,7 +203,15 @@ describe("WhatsAppConnectionController", () => {
.mockResolvedValueOnce(afterTimeoutSock)
.mockResolvedValueOnce(afterPairingRestartSock);
const result = await harness.run({ waitForConnection, createSocket });
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toEqual({
outcome: "connected",
@@ -285,131 +220,34 @@ describe("WhatsAppConnectionController", () => {
});
expect(createSocket).toHaveBeenCalledTimes(2);
expect(waitForConnection).toHaveBeenCalledTimes(3);
expect(waitForConnection).toHaveBeenNthCalledWith(1, harness.initialSock, {
timeout: "none",
});
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
expect(waitForConnection).toHaveBeenNthCalledWith(2, afterTimeoutSock, { timeout: "none" });
expect(waitForConnection).toHaveBeenNthCalledWith(3, afterPairingRestartSock, {
timeout: "none",
});
expect(harness.initialSock.end).toHaveBeenCalledOnce();
expect(initialSock.end).toHaveBeenCalledOnce();
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
});
it("clears stale logged-out auth once and continues login with a fresh socket", async () => {
const harness = createLoginResultHarness();
const error = loggedOutError();
const waitForConnection = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined);
const onQr = vi.fn();
const onSocketReplaced = vi.fn();
const createSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
opts?.onQr?.("qr-after-logout");
return harness.replacementSock;
},
);
const result = await harness.run({
verbose: true,
waitForConnection,
createSocket,
onQr,
onSocketReplaced,
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: harness.replacementSock,
});
expect(logoutWebMock).toHaveBeenCalledWith({
authDir: loginAuthDir,
isLegacyAuthDir: false,
runtime: harness.runtime,
});
expect(harness.initialSock.end).toHaveBeenCalledOnce();
expect(createSocket).toHaveBeenCalledWith(false, true, {
authDir: loginAuthDir,
onQr,
});
expect(onQr).toHaveBeenCalledWith("qr-after-logout");
expect(onSocketReplaced).toHaveBeenCalledWith(harness.replacementSock);
expect(waitForConnection).toHaveBeenNthCalledWith(1, harness.initialSock, {
timeout: "none",
});
expect(waitForConnection).toHaveBeenNthCalledWith(2, harness.replacementSock, {
timeout: "none",
});
});
it("does not retry logged-out login when stale auth cleanup is skipped", async () => {
const { createSocket, error, harness, result, waitForConnection } = await runLoggedOutRecovery({
cleanupCleared: false,
authDecisions: [{ outcome: "stable", exists: true }],
});
expect(result).toEqual({
outcome: "failed",
message:
"existing auth could not be cleared. Remove or fix the configured WhatsApp auth directory, then retry login.",
error,
});
expect(logoutWebMock).toHaveBeenCalledWith({
authDir: loginAuthDir,
isLegacyAuthDir: false,
runtime: harness.runtime,
});
expect(harness.initialSock.end).toHaveBeenCalledOnce();
expect(createSocket).not.toHaveBeenCalled();
expect(waitForConnection).toHaveBeenCalledOnce();
});
it("retries logged-out login when cleanup is a no-op because no auth exists", async () => {
const { createSocket, harness, result, waitForConnection } = await runLoggedOutRecovery({
cleanupCleared: false,
authDecisions: [
{ outcome: "stable", exists: false },
{ outcome: "stable", exists: true },
],
secondWait: "resolve",
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: harness.replacementSock,
});
expect(createSocket).toHaveBeenCalledOnce();
expect(waitForConnection).toHaveBeenNthCalledWith(2, harness.replacementSock, {
timeout: "none",
});
});
it("does not clear stale logged-out auth more than once", async () => {
const { createSocket, error, result, waitForConnection } = await runLoggedOutRecovery({
secondWait: "logged-out",
});
expect(result).toMatchObject({
outcome: "logged-out",
statusCode: DisconnectReason.loggedOut,
error,
});
expect(logoutWebMock).toHaveBeenCalledOnce();
expect(createSocket).toHaveBeenCalledOnce();
expect(waitForConnection).toHaveBeenCalledTimes(2);
});
it("does not keep recreating sockets when login status 408 persists", async () => {
const harness = createLoginResultHarness();
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
const waitForConnection = vi
.fn()
.mockRejectedValueOnce(timeoutError)
.mockRejectedValueOnce(timeoutError);
const createSocket = vi.fn(async () => harness.replacementSock);
const createSocket = vi.fn(async () => replacementSock);
const result = await harness.run({ waitForConnection, createSocket });
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toMatchObject({
outcome: "failed",

View File

@@ -36,8 +36,6 @@ const WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE =
"WhatsApp connected, but saving the linked credentials has not settled on disk yet. Retry login in a moment.";
const WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE =
"WhatsApp connected, but the linked credentials were not found on disk. Retry login in a moment.";
const WHATSAPP_LOGIN_AUTH_NOT_CLEARED_MESSAGE =
"existing auth could not be cleared. Remove or fix the configured WhatsApp auth directory, then retry login.";
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
export const WHATSAPP_WATCHDOG_TIMEOUT_ERROR = "watchdog-timeout";
@@ -238,31 +236,6 @@ export async function waitForWhatsAppLoginResult(params: {
let currentSock = params.sock;
let postPairingRestarted = false;
let timeoutRestarted = false;
let loggedOutRestarted = false;
const replaceLoginSocket = async (
opts: { closeCurrent?: boolean } = {},
): Promise<WhatsAppLoginWaitResult | null> => {
if (opts.closeCurrent ?? true) {
closeWaSocket(currentSock);
}
try {
currentSock = await createSocket(false, params.verbose, {
authDir: params.authDir,
...params.socketTiming,
onQr: params.onQr,
});
params.onSocketReplaced?.(currentSock);
return null;
} catch (createErr) {
return {
outcome: "failed",
message: formatError(createErr),
statusCode: getStatusCode(createErr),
error: createErr,
};
}
};
while (true) {
try {
@@ -285,7 +258,7 @@ export async function waitForWhatsAppLoginResult(params: {
}
return {
outcome: "connected",
restarted: postPairingRestarted || timeoutRestarted || loggedOutRestarted,
restarted: postPairingRestarted || timeoutRestarted,
sock: currentSock,
};
} catch (err) {
@@ -301,51 +274,37 @@ export async function waitForWhatsAppLoginResult(params: {
timeoutRestarted = true;
}
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
const replacementFailure = await replaceLoginSocket();
if (replacementFailure) {
return replacementFailure;
closeWaSocket(currentSock);
try {
currentSock = await createSocket(false, params.verbose, {
authDir: params.authDir,
...params.socketTiming,
onQr: params.onQr,
});
params.onSocketReplaced?.(currentSock);
continue;
} catch (createErr) {
return {
outcome: "failed",
message: formatError(createErr),
statusCode: getStatusCode(createErr),
error: createErr,
};
}
continue;
}
if (statusCode === LOGGED_OUT_STATUS) {
if (loggedOutRestarted) {
return {
outcome: "logged-out",
message: WHATSAPP_LOGGED_OUT_RELINK_MESSAGE,
statusCode: LOGGED_OUT_STATUS,
error: err,
};
}
closeWaSocket(currentSock);
const cleared = await logoutWeb({
await logoutWeb({
authDir: params.authDir,
isLegacyAuthDir: params.isLegacyAuthDir,
runtime: params.runtime,
});
if (!cleared) {
const existingAuth = await readWebAuthExistsForDecision(params.authDir);
if (existingAuth.outcome === "unstable") {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE,
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE),
};
}
if (existingAuth.exists) {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_NOT_CLEARED_MESSAGE,
error: err,
};
}
}
loggedOutRestarted = true;
const replacementFailure = await replaceLoginSocket({ closeCurrent: false });
if (replacementFailure) {
return replacementFailure;
}
continue;
return {
outcome: "logged-out",
message: WHATSAPP_LOGGED_OUT_RELINK_MESSAGE,
statusCode: LOGGED_OUT_STATUS,
error: err,
};
}
return {

View File

@@ -1,7 +1,6 @@
// Whatsapp tests cover login qr plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getActiveWebListener } from "./active-listener.js";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import { renderQrPngDataUrl } from "./qr-image.js";
import {
@@ -42,82 +41,17 @@ vi.mock("./session.js", async () => {
};
});
vi.mock("./active-listener.js", () => ({
getActiveWebListener: vi.fn(() => null),
}));
vi.mock("./qr-image.js", () => ({
renderQrPngBase64: vi.fn(async () => "base64"),
renderQrPngDataUrl: vi.fn(async (input: string) => `data:image/png;base64,encoded:${input}`),
}));
const createWaSocketMock = vi.mocked(createWaSocket);
const getActiveWebListenerMock = vi.mocked(getActiveWebListener);
const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision);
const readWebSelfIdMock = vi.mocked(readWebSelfId);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const logoutWebMock = vi.mocked(logoutWeb);
const renderQrPngDataUrlMock = vi.mocked(renderQrPngDataUrl);
const scanQrMessage = "Scan this QR in WhatsApp → Linked Devices.";
const refreshedQrMessage = "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.";
const cleanupFailureMessage =
"WhatsApp login failed: existing auth could not be cleared. Remove or fix the configured WhatsApp auth directory, then retry login.";
function encodedQr(qr: string) {
return `data:image/png;base64,encoded:${qr}`;
}
function queueQrSocket(qr: string) {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.(qr));
return sock as never;
},
);
}
function queueRotatingQrSocket(firstQr: string, secondQr: string, delayMs: number) {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.(firstQr));
setTimeout(() => opts?.onQr?.(secondQr), delayMs);
return sock as never;
},
);
}
function queueSilentSocket() {
createWaSocketMock.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never);
}
function expectScanQrResult(result: unknown, qr = "qr-data") {
expect(result).toEqual({
qrDataUrl: encodedQr(qr),
message: scanQrMessage,
});
}
function expectQrRefreshResult(result: unknown, qr: string) {
expect(result).toEqual({
connected: false,
message: refreshedQrMessage,
qrDataUrl: encodedQr(qr),
});
}
function waitForever() {
return new Promise<never>(() => {});
}
async function flushTasks() {
await Promise.resolve();
@@ -164,7 +98,6 @@ describe("login-qr", () => {
outcome: "stable",
exists: false,
});
getActiveWebListenerMock.mockReset().mockReturnValue(null);
readWebSelfIdMock.mockReset().mockReturnValue({ e164: null, jid: null, lid: null });
logoutWebMock.mockReset().mockResolvedValue(true);
renderQrPngDataUrlMock
@@ -189,7 +122,7 @@ describe("login-qr", () => {
timeoutMs: 5000,
accountId: rotatingAccountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const resultPromise = waitForWebLogin({
timeoutMs: 5000,
@@ -209,195 +142,62 @@ describe("login-qr", () => {
it("returns a replacement QR when status 408 happens before the first QR", async () => {
const accountId = "timeout-before-first-qr";
queueSilentSocket();
queueQrSocket("qr-after-timeout");
createWaSocketMock
.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never)
.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-after-timeout"));
return sock as never;
},
);
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 408 } })
.mockImplementation(waitForever);
.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expectScanQrResult(start, "qr-after-timeout");
expect(start).toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
message: "Scan this QR in WhatsApp → Linked Devices.",
});
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
});
it("clears auth and returns a replacement QR when WhatsApp is logged out", async () => {
const accountId = "logged-out-replacement-qr";
queueQrSocket("qr-data");
queueQrSocket("qr-after-logout");
waitForWaConnectionMock
.mockRejectedValueOnce({
output: { statusCode: 401 },
})
.mockImplementation(waitForever);
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },
});
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const result = await waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId,
});
expect(result).toEqual({
connected: false,
message: refreshedQrMessage,
qrDataUrl: encodedQr("qr-after-logout"),
message:
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.",
});
expect(logoutWebMock).toHaveBeenCalledOnce();
});
it("keeps the linked shortcut when existing auth has an active listener", async () => {
getActiveWebListenerMock.mockReturnValue({} as never);
readWebSelfIdMock.mockReturnValueOnce({ e164: "+15551234567", jid: null, lid: null });
readWebAuthExistsForDecisionMock.mockResolvedValueOnce({
outcome: "stable",
exists: true,
});
await expect(startWebLoginWithQr({ timeoutMs: 5000 })).resolves.toEqual({
message: "WhatsApp is already linked (+15551234567). Say “relink” if you want a fresh QR.",
});
expect(createWaSocketMock).not.toHaveBeenCalled();
expect(logoutWebMock).not.toHaveBeenCalled();
});
it("clears saved auth for an explicit fresh QR relink", async () => {
const accountId = "force-fresh-qr";
getActiveWebListenerMock.mockReturnValue({} as never);
waitForWaConnectionMock.mockImplementation(waitForever);
readWebAuthExistsForDecisionMock.mockResolvedValueOnce({
outcome: "stable",
exists: true,
});
const result = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
force: true,
});
expectScanQrResult(result);
expect(logoutWebMock).toHaveBeenCalledWith({
authDir: expect.stringContaining(accountId),
isLegacyAuthDir: false,
runtime: expect.anything(),
});
});
it("rederives logged-out auth after restart when preserved creds have no active listener", async () => {
const accountId = "restart-preserved-logged-out";
queueSilentSocket();
queueQrSocket("qr-after-restart-logout");
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 401 } })
.mockImplementation(waitForever);
readWebAuthExistsForDecisionMock.mockResolvedValueOnce({
outcome: "stable",
exists: true,
});
const result = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expectScanQrResult(result, "qr-after-restart-logout");
expect(logoutWebMock).toHaveBeenCalledWith({
authDir: expect.stringContaining(accountId),
isLegacyAuthDir: false,
runtime: expect.anything(),
});
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
});
it("does not start a fresh QR when existing auth cleanup is skipped", async () => {
const accountId = "skipped-cleanup-qr";
queueSilentSocket();
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },
});
logoutWebMock.mockResolvedValueOnce(false);
readWebAuthExistsForDecisionMock
.mockResolvedValueOnce({ outcome: "stable", exists: true })
.mockResolvedValueOnce({ outcome: "stable", exists: true });
const result = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expect(result).toEqual({ message: cleanupFailureMessage });
expect(createWaSocketMock).toHaveBeenCalledOnce();
});
it("reports skipped cleanup during QR login as an auth cleanup failure", async () => {
const accountId = "skipped-cleanup-after-qr";
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },
});
readWebAuthExistsForDecisionMock
.mockResolvedValueOnce({ outcome: "stable", exists: false })
.mockResolvedValueOnce({ outcome: "stable", exists: true });
logoutWebMock.mockResolvedValueOnce(false);
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
await expect(
waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId,
}),
).resolves.toEqual({
connected: false,
message: cleanupFailureMessage,
});
});
it("uses the linked shortcut after successful QR relink starts a listener", async () => {
const accountId = "qr-success-clears-terminal-state";
let finishLogin!: () => void;
waitForWaConnectionMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishLogin = resolve;
}),
);
readWebAuthExistsForDecisionMock
.mockResolvedValueOnce({ outcome: "stable", exists: false })
.mockResolvedValueOnce({ outcome: "stable", exists: true })
.mockResolvedValueOnce({ outcome: "stable", exists: true });
readWebSelfIdMock.mockReturnValue({ e164: "+15551234567", jid: null, lid: null });
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
finishLogin();
await expect(
waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId,
}),
).resolves.toEqual({
connected: true,
message: "✅ Linked! WhatsApp is ready.",
});
logoutWebMock.mockClear();
getActiveWebListenerMock.mockReturnValue({} as never);
await expect(startWebLoginWithQr({ timeoutMs: 5000, accountId })).resolves.toEqual({
message: "WhatsApp is already linked (+15551234567). Say “relink” if you want a fresh QR.",
});
expect(logoutWebMock).not.toHaveBeenCalled();
});
it("caps oversized wait timeouts to a timer-safe delay", async () => {
const accountId = "oversized-wait-timeout";
waitForWaConnectionMock.mockImplementation(waitForever);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
vi.useFakeTimers();
const resultPromise = waitForWebLogin({
@@ -420,7 +220,7 @@ describe("login-qr", () => {
logoutWebMock.mockRejectedValueOnce(new Error("cleanup failed"));
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const result = await waitForWebLogin({
timeoutMs: 5000,
@@ -473,7 +273,7 @@ describe("login-qr", () => {
expect(result.message).toMatch(/retry/i);
});
it("reports a recovered linked session when saved auth has no active listener", async () => {
it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
@@ -486,7 +286,9 @@ describe("login-qr", () => {
);
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null });
readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: true });
readWebAuthExistsForDecisionMock
.mockResolvedValueOnce({ outcome: "stable", exists: false })
.mockResolvedValue({ outcome: "stable", exists: true });
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
@@ -502,11 +304,22 @@ describe("login-qr", () => {
});
it("surfaces the latest QR after the socket rotates it", async () => {
queueRotatingQrSocket("qr-data", "qr-data-2", 100);
waitForWaConnectionMock.mockImplementation(waitForever);
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 100);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const resultPromise = waitForWebLogin({
timeoutMs: 5000,
@@ -516,7 +329,11 @@ describe("login-qr", () => {
await waitMs(140);
await flushTasks();
expectQrRefreshResult(await resultPromise, "qr-data-2");
await expect(resultPromise).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
});
it("does not short-circuit on an existing QR when the waiter has no current QR image", async () => {
@@ -535,7 +352,7 @@ describe("login-qr", () => {
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
await expect(
waitForWebLogin({
@@ -553,7 +370,18 @@ describe("login-qr", () => {
let resolveLogin: () => void = () => {
throw new Error("Expected login wait to be pending");
};
queueRotatingQrSocket("qr-data", "qr-data-2", 20);
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 20);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
@@ -568,7 +396,7 @@ describe("login-qr", () => {
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
await waitMs(50);
await flushTasks();
@@ -599,13 +427,13 @@ describe("login-qr", () => {
resolveFirstConnection = resolve;
}),
)
.mockImplementation(waitForever);
.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const waiter = waitForWebLogin({
timeoutMs: 1000,
@@ -621,7 +449,7 @@ describe("login-qr", () => {
timeoutMs: 5000,
accountId,
});
expect(replacement.qrDataUrl).toBe(encodedQr("qr-data"));
expect(replacement.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
resolveFirstConnection();
@@ -651,7 +479,7 @@ describe("login-qr", () => {
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(waitForever);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
renderQrPngDataUrlMock.mockImplementation((qr) =>
qr === "qr-data-2"
? new Promise<string>(() => {})
@@ -662,7 +490,7 @@ describe("login-qr", () => {
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
onQr("qr-data-2");
await flushTasks();
@@ -674,7 +502,7 @@ describe("login-qr", () => {
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(reused).toEqual({
qrDataUrl: encodedQr("qr-data"),
qrDataUrl: "data:image/png;base64,encoded:qr-data",
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
});
});
@@ -690,7 +518,7 @@ describe("login-qr", () => {
resolveRender = resolve;
}),
);
waitForWaConnectionMock.mockImplementation(waitForever);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const resultPromise = startWebLoginWithQr({
timeoutMs: 5000,
@@ -700,20 +528,34 @@ describe("login-qr", () => {
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
resolveRender(encodedQr("qr-data"));
expectScanQrResult(await resultPromise);
resolveRender("data:image/png;base64,encoded:qr-data");
await expect(resultPromise).resolves.toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-data",
message: "Scan this QR in WhatsApp → Linked Devices.",
});
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
});
it("returns the same rotated QR to concurrent waiters that share the same current image", async () => {
queueRotatingQrSocket("qr-data", "qr-data-2", 100);
waitForWaConnectionMock.mockImplementation(waitForever);
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 100);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId: concurrentAccountId,
});
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const waiterA = waitForWebLogin({
timeoutMs: 5000,
@@ -730,7 +572,15 @@ describe("login-qr", () => {
await waitMs(140);
await flushTasks();
expectQrRefreshResult(await waiterA, "qr-data-2");
expectQrRefreshResult(await waiterB, "qr-data-2");
await expect(waiterA).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
await expect(waiterB).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
});
});

View File

@@ -6,7 +6,6 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { danger, info, success } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppAccount } from "./accounts.js";
import { getActiveWebListener } from "./active-listener.js";
import {
closeWaSocket,
waitForWhatsAppLoginResult,
@@ -15,8 +14,6 @@ import {
import { renderQrPngDataUrl } from "./qr-image.js";
import {
createWaSocket,
formatError,
logoutWeb,
readWebAuthExistsForDecision,
readWebSelfId,
WHATSAPP_AUTH_UNSTABLE_CODE,
@@ -330,32 +327,13 @@ export async function startWebLoginWithQr(
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
};
}
if (authState.exists && !opts.force && getActiveWebListener(account.accountId)) {
if (authState.exists && !opts.force) {
const selfId = readWebSelfId(account.authDir);
const who = selfId.e164 ?? selfId.jid ?? "unknown";
return {
message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`,
};
}
if (authState.exists && opts.force) {
try {
const cleared = await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
runtime,
});
if (!cleared) {
return {
message:
"WhatsApp login failed: existing auth could not be cleared. Remove or fix the configured WhatsApp auth directory, then retry login.",
};
}
} catch (err) {
return {
message: `WhatsApp login failed: ${formatError(err)}`,
};
}
}
const existing = activeLogins.get(account.accountId);
if (existing && isLoginFresh(existing) && existing.qrDataUrl) {

View File

@@ -168,21 +168,18 @@ describe("loginWeb coverage", () => {
expect(renderQrTerminalMock).toHaveBeenCalledWith("restart-qr", { small: true });
});
it("clears stale creds and continues login when logged out", async () => {
waitForWaConnectionMock
.mockRejectedValueOnce({
output: { statusCode: 401 },
})
.mockResolvedValueOnce(undefined);
it("clears creds and throws when logged out", async () => {
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },
});
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await loginWeb(false, waitForWaConnectionMock as never, runtime);
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtimeMessageCalls(runtime.log)).toContain(
"✅ Linked after restart; web session ready.",
await expect(loginWeb(false, waitForWaConnectionMock as never, runtime)).rejects.toThrow(
/cache cleared/i,
);
expect(runtimeMessageCalls(runtime.error)).toEqual([
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.",
]);
expect(rmMock).toHaveBeenCalledWith(path.resolve(testState.authDir), {
recursive: true,
force: true,

View File

@@ -8,9 +8,6 @@ import { CONFIG_DIR, resolveUserPath } from "openclaw/plugin-sdk/text-utility-ru
const WHATSAPP_FENCE_PLACEHOLDER = "\x00FENCE";
const WHATSAPP_INLINE_CODE_PLACEHOLDER = "\x00CODE";
// Terminates the numeric index in a placeholder so the restore regex cannot
// absorb a digit from adjacent user text (e.g. `code`5) into the index.
const WHATSAPP_PLACEHOLDER_TERMINATOR = "\x00";
export type WebChannel = "web";
@@ -200,26 +197,25 @@ export function markdownToWhatsApp(text: string): string {
const fences: string[] = [];
let result = text.replace(/```[\s\S]*?```/g, (match) => {
fences.push(match);
return `${WHATSAPP_FENCE_PLACEHOLDER}${fences.length - 1}${WHATSAPP_PLACEHOLDER_TERMINATOR}`;
return `${WHATSAPP_FENCE_PLACEHOLDER}${fences.length - 1}`;
});
const inlineCodes: string[] = [];
result = result.replace(/`[^`\n]+`/g, (match) => {
inlineCodes.push(match);
return `${WHATSAPP_INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}${WHATSAPP_PLACEHOLDER_TERMINATOR}`;
return `${WHATSAPP_INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}`;
});
result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
result = result.replace(/__(.+?)__/g, "*$1*");
result = result.replace(/~~(.+?)~~/g, "~$1~");
const terminator = escapeRegExp(WHATSAPP_PLACEHOLDER_TERMINATOR);
result = result.replace(
new RegExp(`${escapeRegExp(WHATSAPP_INLINE_CODE_PLACEHOLDER)}(\\d+)${terminator}`, "g"),
new RegExp(`${escapeRegExp(WHATSAPP_INLINE_CODE_PLACEHOLDER)}(\\d+)`, "g"),
(_, idx) => inlineCodes[Number(idx)] ?? "",
);
result = result.replace(
new RegExp(`${escapeRegExp(WHATSAPP_FENCE_PLACEHOLDER)}(\\d+)${terminator}`, "g"),
new RegExp(`${escapeRegExp(WHATSAPP_FENCE_PLACEHOLDER)}(\\d+)`, "g"),
(_, idx) => fences[Number(idx)] ?? "",
);
return result;

View File

@@ -41,12 +41,6 @@ describe("markdownToWhatsApp", () => {
["returns empty string for empty input", "", ""],
["returns plain text unchanged", "no formatting here", "no formatting here"],
["handles bold inside a sentence", "This is **very** important", "This is *very* important"],
// Regression: a digit immediately after an inline-code span must not be
// absorbed into the placeholder index (which previously dropped both).
["preserves inline code immediately followed by a digit", "`a`5", "`a`5"],
["preserves inline code followed by a number", "`status`200 done", "`status`200 done"],
["preserves two adjacent code+digit spans", "`x`1 and `y`2", "`x`1 and `y`2"],
["preserves inline code with a space before a digit", "`a` 5", "`a` 5"],
] as const)("handles markdown-to-whatsapp conversion: %s", (_name, input, expected) => {
expect(markdownToWhatsApp(input)).toBe(expected);
});
@@ -56,11 +50,6 @@ describe("markdownToWhatsApp", () => {
expect(markdownToWhatsApp(input)).toBe(input);
});
it("preserves a fenced code block immediately followed by a digit", () => {
const input = "```code```7 done";
expect(markdownToWhatsApp(input)).toBe(input);
});
it("preserves code block with formatting inside", () => {
const input = "Before ```**bold** and ~~strike~~``` after **real bold**";
expect(markdownToWhatsApp(input)).toBe(

View File

@@ -1,42 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
close: vi.fn(),
configureSqliteConnectionPragmas: vi.fn(),
}));
vi.mock("node:sqlite", () => ({
DatabaseSync: vi.fn(function DatabaseSync() {
return { close };
}),
}));
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
configureSqliteConnectionPragmas,
}));
import { createWorkboardSqliteStores } from "./sqlite-store.js";
describe("Workboard SQLite policy", () => {
beforeEach(() => {
close.mockClear();
configureSqliteConnectionPragmas.mockReset();
});
it("closes a newly opened database when filesystem policy refuses it", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
const dbPath = path.join(dir, "workboard.sqlite");
configureSqliteConnectionPragmas.mockImplementation(() => {
throw new Error("SSHFS is unsupported");
});
try {
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
expect(close).toHaveBeenCalledTimes(1);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -2,7 +2,6 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type {
PersistedWorkboardAttachment,
@@ -362,6 +361,15 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
).run(`schema-${SCHEMA_VERSION}`, Date.now());
}
function configureWorkboardDatabase(db: DatabaseSync): void {
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = ${WORKBOARD_SQLITE_BUSY_TIMEOUT_MS};
PRAGMA foreign_keys = ON;
`);
}
function chmodIfExists(targetPath: string, mode: number): void {
try {
fs.chmodSync(targetPath, mode);
@@ -377,40 +385,19 @@ function hardenWorkboardDatabaseFiles(dbPath: string): void {
chmodIfExists(dbPath, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-wal`, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-shm`, WORKBOARD_SQLITE_FILE_MODE);
chmodIfExists(`${dbPath}-journal`, WORKBOARD_SQLITE_FILE_MODE);
}
function createDatabase(dbPath: string): {
db: DatabaseSync;
maintenance: ReturnType<typeof configureSqliteConnectionPragmas>;
} {
function createDatabase(dbPath: string): DatabaseSync {
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: WORKBOARD_SQLITE_DIR_MODE });
chmodIfExists(path.dirname(dbPath), WORKBOARD_SQLITE_DIR_MODE);
if (!fs.existsSync(dbPath)) {
fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE));
}
const db = new DatabaseSync(dbPath);
let maintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
try {
maintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
checkpointIntervalMs: 0,
databaseLabel: "workboard database",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
ensureWorkboardSchema(db);
hardenWorkboardDatabaseFiles(dbPath);
return { db, maintenance };
} catch (error) {
try {
maintenance?.close();
} finally {
db.close();
}
throw error;
}
configureWorkboardDatabase(db);
ensureWorkboardSchema(db);
hardenWorkboardDatabaseFiles(dbPath);
return db;
}
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {
@@ -1414,17 +1401,12 @@ export function createWorkboardSqliteStores(
env?: NodeJS.ProcessEnv;
} = {},
): WorkboardSqliteStores {
const { db, maintenance } = createDatabase(
options.dbPath ?? resolveWorkboardSqlitePath(options.env),
);
const db = createDatabase(options.dbPath ?? resolveWorkboardSqlitePath(options.env));
return {
cards: new WorkboardSqliteCardStore(db),
boards: new WorkboardSqliteBoardStore(db),
subscriptions: new WorkboardSqliteSubscriptionStore(db),
attachments: new WorkboardSqliteAttachmentStore(db),
close: () => {
maintenance.close();
db.close();
},
close: () => db.close(),
};
}

View File

@@ -36,18 +36,6 @@ function createMemoryStore<T = PersistedWorkboardCard>(options?: {
};
}
function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
return {
type,
bsize: 1024,
blocks: 1,
bfree: 1,
bavail: 1,
files: 0,
ffree: 0,
};
}
describe("WorkboardStore", () => {
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
@@ -102,7 +90,7 @@ describe("WorkboardStore", () => {
if (process.platform !== "win32") {
expect(fs.statSync(dir).mode & 0o777).toBe(0o700);
expect(fs.statSync(dbPath).mode & 0o777).toBe(0o600);
for (const sidecarPath of [`${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`]) {
for (const sidecarPath of [`${dbPath}-wal`, `${dbPath}-shm`]) {
if (fs.existsSync(sidecarPath)) {
expect(fs.statSync(sidecarPath).mode & 0o777).toBe(0o600);
}
@@ -156,27 +144,6 @@ describe("WorkboardStore", () => {
}
});
it("uses rollback journaling on network-backed volumes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
const dbPath = path.join(dir, "workboard.sqlite");
const statfs = vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0xff534d42));
try {
const stores = createWorkboardSqliteStores({ dbPath });
stores.close();
const rawDb = new DatabaseSync(dbPath);
expect(rawDb.prepare("PRAGMA journal_mode").get()).toMatchObject({
journal_mode: "delete",
});
rawDb.close();
expect(fs.existsSync(`${dbPath}-wal`)).toBe(false);
expect(fs.existsSync(`${dbPath}-shm`)).toBe(false);
} finally {
statfs.mockRestore();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("creates and lists cards by status order and position", async () => {
const store = new WorkboardStore(createMemoryStore());

42
pnpm-lock.yaml generated
View File

@@ -1353,9 +1353,6 @@ importers:
'@openclaw/whatsapp':
specifier: workspace:*
version: link:../whatsapp
crabline:
specifier: github:openclaw/crabline#5e8031a660f9f8c40746c79830c7caf780080ee7
version: https://codeload.github.com/openclaw/crabline/tar.gz/5e8031a660f9f8c40746c79830c7caf780080ee7
openclaw:
specifier: 2026.5.28
version: 2026.5.28
@@ -4951,32 +4948,6 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crabline@https://codeload.github.com/openclaw/crabline/tar.gz/5e8031a660f9f8c40746c79830c7caf780080ee7:
resolution: {gitHosted: true, integrity: sha512-1lwm5SoLeQkoSa3HIxlgV5g+8rmYjwwTCxpB/lVcX7fzmuH/OyQsRQ6EUhhoszIE0UekbctU0IqmFusv4QUhng==, tarball: https://codeload.github.com/openclaw/crabline/tar.gz/5e8031a660f9f8c40746c79830c7caf780080ee7}
version: 0.1.0
engines: {node: '>=22'}
hasBin: true
peerDependencies:
'@beeper/chat-adapter-matrix': ^0.1.0
'@chat-adapter/discord': ^4.30.0
'@chat-adapter/slack': ^4.30.0
'@chat-adapter/state-memory': ^4.30.0
chat: ^4.30.0
chat-adapter-imessage: ^0.1.1
peerDependenciesMeta:
'@beeper/chat-adapter-matrix':
optional: true
'@chat-adapter/discord':
optional: true
'@chat-adapter/slack':
optional: true
'@chat-adapter/state-memory':
optional: true
chat:
optional: true
chat-adapter-imessage:
optional: true
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
@@ -8390,7 +8361,7 @@ snapshots:
'@copilotkit/aimock@1.27.3(vitest@4.1.8)':
optionalDependencies:
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))
'@create-markdown/preview@2.0.3(shiki@4.1.0)':
optionalDependencies:
@@ -10290,9 +10261,9 @@ snapshots:
obug: 2.1.2
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))
optionalDependencies:
'@vitest/browser': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/expect@4.1.8':
dependencies:
@@ -10823,13 +10794,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
crabline@https://codeload.github.com/openclaw/crabline/tar.gz/5e8031a660f9f8c40746c79830c7caf780080ee7:
dependencies:
commander: 14.0.3
picocolors: 1.1.1
yaml: 2.9.0
zod: 4.4.3
croner@10.0.1: {}
cross-spawn@7.0.6:

View File

@@ -130,7 +130,6 @@ allowBuilds:
"@tloncorp/tlon-skill": true
baileys: true
authenticate-pam: true
crabline: true
"@discordjs/opus": false
esbuild: true
koffi: false

View File

@@ -6,7 +6,6 @@ scenario:
coverage:
primary:
- channels.threads
- thread-parent-child-placement
secondary:
- channels.qa-channel
objective: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel.

Some files were not shown because too many files have changed in this diff Show More