mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
65 Commits
codex/tele
...
codex/mult
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7ad2606a | ||
|
|
939b63f97f | ||
|
|
aaecaf73ef | ||
|
|
65a9701655 | ||
|
|
e98396b199 | ||
|
|
06c0aafaed | ||
|
|
9a8223766a | ||
|
|
f051e2d752 | ||
|
|
a9d4c7ff45 | ||
|
|
33ceb2728e | ||
|
|
da14bfb64f | ||
|
|
33418ff600 | ||
|
|
00160ea6ee | ||
|
|
ffb67d2d2e | ||
|
|
d89ab2c014 | ||
|
|
11a0ad10e9 | ||
|
|
9b6bed7a75 | ||
|
|
f87d194b8b | ||
|
|
386b0e6c74 | ||
|
|
ee495abda1 | ||
|
|
147e979713 | ||
|
|
1ee788189a | ||
|
|
e71cf0ffcb | ||
|
|
3c65127827 | ||
|
|
a4e7d9a0db | ||
|
|
ac8a3f367c | ||
|
|
8694fe7e81 | ||
|
|
073343e2e2 | ||
|
|
aa0d710085 | ||
|
|
c70b9849d9 | ||
|
|
919c5b7c7b | ||
|
|
5296dc378f | ||
|
|
a447f9a43d | ||
|
|
04b7e192af | ||
|
|
450060d7a2 | ||
|
|
6bc57ca73a | ||
|
|
ea346f4361 | ||
|
|
d5c9e7ea99 | ||
|
|
9eed9c5758 | ||
|
|
1c2363def6 | ||
|
|
b7d53800d6 | ||
|
|
6326395c0a | ||
|
|
568f2d5631 | ||
|
|
e94b666e45 | ||
|
|
ee3b7eb7c0 | ||
|
|
2365a137d8 | ||
|
|
dc09d148bb | ||
|
|
55263b3dfa | ||
|
|
01acb34bdb | ||
|
|
03e3ef86af | ||
|
|
eac3e08cfd | ||
|
|
a375d6c849 | ||
|
|
9dbf8f718f | ||
|
|
fd806ada64 | ||
|
|
4ca8bf086c | ||
|
|
b41c0b6746 | ||
|
|
52d9d16e1b | ||
|
|
0ef8620746 | ||
|
|
74c6f175c7 | ||
|
|
0d50ec77de | ||
|
|
eccfacb02c | ||
|
|
f08b24e63c | ||
|
|
c32ba171db | ||
|
|
e64379dddb | ||
|
|
127e174c9e |
@@ -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.
|
||||
- Keep going until structured review returns no accepted/actionable findings only while the work remains inside the original task scope.
|
||||
- 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,6 +43,42 @@ 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:
|
||||
|
||||
@@ -440,8 +440,36 @@ 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.
|
||||
@@ -463,8 +491,11 @@ 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}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -145,8 +146,23 @@ 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 = [
|
||||
|
||||
@@ -65,6 +65,13 @@ 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 \
|
||||
|
||||
@@ -552,6 +552,16 @@ 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`.
|
||||
@@ -720,8 +730,13 @@ 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 recovery probe:
|
||||
verifier command remains the first 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
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -1523,7 +1523,13 @@ jobs:
|
||||
fi
|
||||
;;
|
||||
session-transcript-reader-boundary)
|
||||
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp: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
|
||||
;;
|
||||
extension-channels)
|
||||
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
|
||||
|
||||
107
.github/workflows/full-release-validation.yml
vendored
107
.github/workflows/full-release-validation.yml
vendored
@@ -275,7 +275,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local 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,8 +298,6 @@ 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="$(
|
||||
@@ -309,20 +307,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
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
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -423,7 +408,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count
|
||||
local 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
|
||||
@@ -446,8 +431,6 @@ 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="$(
|
||||
@@ -457,20 +440,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
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
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -581,7 +551,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url poll_count run_json
|
||||
local 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
|
||||
@@ -604,8 +574,6 @@ 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="$(
|
||||
@@ -615,20 +583,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
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
|
||||
echo "::error::gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -928,8 +883,6 @@ 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
|
||||
@@ -946,22 +899,16 @@ jobs:
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "Could not find dispatched run for npm-telegram-beta-e2e.yml." >&2
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1073,31 +1020,23 @@ jobs:
|
||||
echo "- Release impact: advisory"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
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 \
|
||||
dispatch_output="$(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
|
||||
|
||||
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
|
||||
-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
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
|
||||
echo "::warning::gh workflow run openclaw-performance.yml did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -1112,13 +1112,14 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
|
||||
local release_version body_file notes_file evidence_path 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"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
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}")"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
|
||||
3
.github/workflows/workflow-sanity.yml
vendored
3
.github/workflows/workflow-sanity.yml
vendored
@@ -251,3 +251,6 @@ 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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
303312830e2d7275bfe5abcdbdb3b47fd8648067a7b51ca043503a78bb18d275 plugin-sdk-api-baseline.json
|
||||
71e94e1de9f1b03aa44da55ec63d16146ab279740c44854d5998bc0f04d6ae0d plugin-sdk-api-baseline.jsonl
|
||||
ac9d5235efb4880f833565fd67b34722314e20aaa2600eb6e27013b2338dbce5 plugin-sdk-api-baseline.json
|
||||
a0a90fa7538dddf602f66d8d6d6677fb38f03a90fd5a7a2f0c8e50f2e65f4963 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -418,7 +418,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich message formatting">
|
||||
Outbound text uses Telegram rich messages.
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- 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.
|
||||
@@ -426,6 +438,8 @@ 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>
|
||||
@@ -1081,7 +1095,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`, `linkPreview`, `responsePrefix`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `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`
|
||||
|
||||
@@ -54,7 +54,8 @@ 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, and tool metadata looks like this:
|
||||
data-handling posture, config secret provider/auth profile posture, exec approval
|
||||
file posture, and tool metadata looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -145,6 +146,15 @@ data-handling posture, config secret provider/auth profile posture, and tool met
|
||||
"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": {
|
||||
@@ -187,9 +197,11 @@ 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. 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
|
||||
`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
|
||||
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.
|
||||
@@ -218,8 +230,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.*`, and
|
||||
`dataHandling.memory.*`. Channel-scoped
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, `dataHandling.memory.*`,
|
||||
and `execApprovals.*`. 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
|
||||
@@ -304,10 +316,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`, and `dataHandling.memory` | 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`, `dataHandling.memory`, and `execApprovals` | 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.
|
||||
|
||||
@@ -401,6 +413,69 @@ 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 |
|
||||
@@ -769,6 +844,13 @@ 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. |
|
||||
|
||||
@@ -76,6 +76,14 @@ 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. Individual scenarios own any
|
||||
specific channel requirement through `execution.channel`; if a scenario does not
|
||||
specify one, the driver default is used. `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 overriding that same host
|
||||
driver path.
|
||||
|
||||
## Operator flow
|
||||
|
||||
The current QA operator flow is a two-pane QA site:
|
||||
|
||||
@@ -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>` fully controls session routing.
|
||||
- `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-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` when you need explicit routing control across multiple clients or threads.
|
||||
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:`.
|
||||
|
||||
## Why this surface matters
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| `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/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 |
|
||||
|
||||
@@ -44,10 +44,8 @@ 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 || canStreamProgressDraftForToolOnlySource) &&
|
||||
!params.sourceRepliesAreToolOnly &&
|
||||
discordStreamMode !== "off" &&
|
||||
!accountBlockStreamingEnabled;
|
||||
const draftStream = canStreamDraft
|
||||
|
||||
@@ -2154,12 +2154,12 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
|
||||
expect(
|
||||
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
|
||||
).toBeUndefined();
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
|
||||
return createNoQueuedDispatchResult();
|
||||
@@ -2179,7 +2179,36 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done");
|
||||
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(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -981,9 +981,7 @@ async function processDiscordMessageInner(
|
||||
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
|
||||
suppressTyping: isRoomEvent ? true : undefined,
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed:
|
||||
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
|
||||
? true
|
||||
: undefined,
|
||||
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
|
||||
disableBlockStreaming: sourceRepliesAreToolOnly
|
||||
? true
|
||||
: (draftPreview.disableBlockStreamingForDraft ??
|
||||
@@ -1001,9 +999,11 @@ async function processDiscordMessageInner(
|
||||
? () => draftPreview.handleAssistantMessageBoundary()
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
suppressDefaultToolProgressMessages: draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
suppressDefaultToolProgressMessages:
|
||||
(sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled) ||
|
||||
draftPreview.suppressDefaultToolProgressMessages
|
||||
? true
|
||||
: undefined,
|
||||
commentaryProgressEnabled: draftPreview.isProgressMode
|
||||
? draftPreview.commentaryProgressEnabled
|
||||
: undefined,
|
||||
|
||||
@@ -51,13 +51,13 @@ describe("createButtonTemplate", () => {
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
|
||||
it("keeps longer text when thumbnail is provided", () => {
|
||||
it("truncates text to 60 chars when title and thumbnail are 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(100);
|
||||
expect((template.template as { text: string }).text.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,12 +77,67 @@ describe("createCarouselColumn", () => {
|
||||
expect(column.actions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates text to 120 characters", () => {
|
||||
it("truncates text to 120 characters when no title or image is set", () => {
|
||||
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", () => {
|
||||
@@ -131,6 +186,20 @@ 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", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ 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;
|
||||
@@ -30,6 +33,48 @@ 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)
|
||||
*/
|
||||
@@ -68,12 +113,15 @@ export function createButtonTemplate(
|
||||
altText?: string;
|
||||
},
|
||||
): TemplateMessage {
|
||||
const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
|
||||
const textLimit = hasThumbnail ? 160 : 60;
|
||||
const textLimit = resolveTemplateTextLimit({
|
||||
title,
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
textOnlyLimit: 160,
|
||||
});
|
||||
const template: ButtonsTemplate = {
|
||||
type: "buttons",
|
||||
title: title.slice(0, 40), // LINE limit
|
||||
text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
|
||||
text: truncateTemplateText(text, textLimit),
|
||||
actions: actions.slice(0, 4), // LINE limit: max 4 actions
|
||||
thumbnailImageUrl: options?.thumbnailImageUrl,
|
||||
imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
|
||||
@@ -125,9 +173,14 @@ 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: params.text.slice(0, 120), // LINE limit
|
||||
text: truncateTemplateText(params.text, textLimit),
|
||||
actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
|
||||
thumbnailImageUrl: params.thumbnailImageUrl,
|
||||
imageBackgroundColor: params.imageBackgroundColor,
|
||||
@@ -256,9 +309,7 @@ export function createProductCarousel(
|
||||
|
||||
return createCarouselColumn({
|
||||
title: product.title,
|
||||
text: product.price
|
||||
? `${product.description}\n${product.price}`.slice(0, 120)
|
||||
: product.description,
|
||||
text: formatProductCarouselText(product.description, product.price),
|
||||
thumbnailImageUrl: product.imageUrl,
|
||||
actions,
|
||||
});
|
||||
|
||||
@@ -74,6 +74,14 @@ 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) {
|
||||
@@ -236,6 +244,27 @@ 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) {
|
||||
@@ -249,8 +278,30 @@ describe("mattermostPlugin", () => {
|
||||
threadId: "other-thread",
|
||||
}),
|
||||
).toEqual({
|
||||
replyToId: "post-parent",
|
||||
threadId: "post-parent",
|
||||
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",
|
||||
});
|
||||
expect(
|
||||
resolveReplyTransport({
|
||||
@@ -402,6 +453,17 @@ describe("mattermostPlugin", () => {
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveAutoThreadId({
|
||||
cfg: {},
|
||||
to: "tqfek9psh7fw8mpa5berwyytqw",
|
||||
toolContext: {
|
||||
currentChannelId: "channel:tqfek9psh7fw8mpa5berwyytqw",
|
||||
currentThreadTs: "root-1",
|
||||
replyToMode: "all",
|
||||
},
|
||||
}),
|
||||
).toBe("root-1");
|
||||
expect(
|
||||
resolveAutoThreadId({
|
||||
cfg: {},
|
||||
@@ -714,7 +776,7 @@ describe("mattermostPlugin", () => {
|
||||
expect(options.replyToId).toBe("post-root");
|
||||
});
|
||||
|
||||
it("keeps explicit reply precedence when threadId is also provided", async () => {
|
||||
it("uses threadId as the Mattermost root when generic replyTo names a child post", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.(
|
||||
@@ -732,7 +794,29 @@ describe("mattermostPlugin", () => {
|
||||
);
|
||||
|
||||
const options = expectSingleMattermostSend("channel:CHAN1", "hello");
|
||||
expect(options.replyToId).toBe("child-post");
|
||||
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");
|
||||
});
|
||||
|
||||
it("routes filePath send actions through Mattermost media upload options", async () => {
|
||||
|
||||
@@ -258,10 +258,8 @@ function resolveMattermostAutoThreadId(params: {
|
||||
typeof context?.currentMessageId === "number"
|
||||
? String(context.currentMessageId)
|
||||
: normalizeOptionalString(context?.currentMessageId);
|
||||
const currentTarget = context?.currentChannelId
|
||||
? normalizeMattermostMessagingTarget(context.currentChannelId)
|
||||
: undefined;
|
||||
if (currentThreadId && currentTarget === normalizeMattermostMessagingTarget(params.to)) {
|
||||
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
|
||||
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
|
||||
if (replyToId === currentMessageId) {
|
||||
return currentThreadId;
|
||||
}
|
||||
@@ -276,6 +274,28 @@ 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);
|
||||
}
|
||||
@@ -420,12 +440,13 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
: typeof params.message === "string"
|
||||
? params.message
|
||||
: "";
|
||||
// Match the shared runner semantics: trim empty reply IDs away before
|
||||
// falling back from replyToId to replyTo on direct plugin calls.
|
||||
// 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.
|
||||
const replyToId =
|
||||
normalizeOptionalString(params.replyToId) ??
|
||||
normalizeOptionalString(params.replyTo) ??
|
||||
normalizeOptionalString(params.threadId);
|
||||
normalizeOptionalString(params.threadId) ??
|
||||
normalizeOptionalString(params.replyTo);
|
||||
const resolvedAccountId = accountId || undefined;
|
||||
|
||||
const attachmentMedia = collectMattermostAttachmentMedia(params);
|
||||
@@ -896,16 +917,18 @@ 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
|
||||
: replyToIsExplicit
|
||||
? (replyToId ?? ambientThreadId)
|
||||
: replyDelivery
|
||||
? (ambientThreadId ?? replyToId ?? undefined)
|
||||
: (replyToId ?? ambientThreadId);
|
||||
: replyDelivery
|
||||
? replyToIsExplicit
|
||||
? (replyToId ?? ambientThreadId)
|
||||
: (ambientThreadId ?? replyToId ?? undefined)
|
||||
: (ambientThreadId ?? replyToId);
|
||||
return {
|
||||
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
|
||||
threadId: resolvedThreadId ?? null,
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
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;
|
||||
@@ -26,6 +30,13 @@ 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;
|
||||
@@ -76,8 +87,7 @@ export async function moveMemoryIndexFiles(
|
||||
options: MemoryIndexFileOptions = {},
|
||||
): Promise<void> {
|
||||
const resolvedOptions = resolveMemoryIndexFileOptions(options);
|
||||
const suffixes = ["", "-wal", "-shm"];
|
||||
for (const suffix of suffixes) {
|
||||
for (const suffix of memoryIndexFileSuffixes) {
|
||||
const source = `${sourceBase}${suffix}`;
|
||||
const target = `${targetBase}${suffix}`;
|
||||
await renameWithRetry(source, target, resolvedOptions, suffix !== "");
|
||||
@@ -107,8 +117,7 @@ export async function removeMemoryIndexFiles(
|
||||
options: MemoryIndexFileOptions = {},
|
||||
): Promise<void> {
|
||||
const resolvedOptions = resolveMemoryIndexFileOptions(options);
|
||||
const suffixes = ["", "-wal", "-shm"];
|
||||
for (const suffix of suffixes) {
|
||||
for (const suffix of memoryIndexFileSuffixes) {
|
||||
await rmWithRetry(`${basePath}${suffix}`, resolvedOptions);
|
||||
}
|
||||
}
|
||||
@@ -117,8 +126,9 @@ async function removeMemoryIndexSidecars(
|
||||
basePath: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
): Promise<void> {
|
||||
await rmWithRetry(`${basePath}-wal`, options);
|
||||
await rmWithRetry(`${basePath}-shm`, options);
|
||||
for (const suffix of memoryIndexSidecarSuffixes) {
|
||||
await rmWithRetry(`${basePath}${suffix}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMemoryIndexSidecars(
|
||||
@@ -126,8 +136,7 @@ async function moveMemoryIndexSidecars(
|
||||
targetBase: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
): Promise<void> {
|
||||
const suffixes = ["-wal", "-shm"];
|
||||
for (const suffix of suffixes) {
|
||||
for (const suffix of memoryIndexSidecarSuffixes) {
|
||||
await renameWithRetry(`${sourceBase}${suffix}`, `${targetBase}${suffix}`, options, true);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +228,9 @@ 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,
|
||||
@@ -241,5 +252,7 @@ export async function runMemoryAtomicReindex<T>(params: {
|
||||
throw aggregateErr;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
swapLock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ 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";
|
||||
|
||||
@@ -19,6 +22,17 @@ 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;
|
||||
@@ -45,7 +59,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
expect(db).toBeDefined();
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
});
|
||||
|
||||
it("allows creating a new database when allowCreate is true", async () => {
|
||||
@@ -53,12 +67,25 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false, true);
|
||||
expect(db).toBeDefined();
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
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 });
|
||||
@@ -71,7 +98,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
|
||||
reindexLock.release();
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false, true);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
});
|
||||
|
||||
it("refuses to auto-create an empty database when allowCreate is false", async () => {
|
||||
@@ -94,7 +121,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
|
||||
const reopen = openMemoryDatabaseAtPath(dbPath, false, false);
|
||||
expect(reopen).toBeDefined();
|
||||
reopen.close();
|
||||
closeMemoryDatabase(reopen);
|
||||
});
|
||||
|
||||
it("removes aged orphan reindex temp files before opening the live database", async () => {
|
||||
@@ -114,7 +141,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
}
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expectPathMissing(orphanBase);
|
||||
await expectPathMissing(`${orphanBase}-wal`);
|
||||
@@ -141,7 +168,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
}
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expectPathMissing(orphanBase);
|
||||
await expectPathMissing(`${orphanBase}-journal`);
|
||||
@@ -164,7 +191,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
await fs.utimes(strandedJournal, old, old);
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expectPathMissing(strandedJournal);
|
||||
});
|
||||
@@ -183,7 +210,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
}
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expect(fs.access(activeBase)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${activeBase}-wal`)).resolves.toBeUndefined();
|
||||
@@ -209,7 +236,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
|
||||
cleanupAgedMemoryReindexTempFiles(dbPath);
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expect(fs.access(activeBase)).resolves.toBeUndefined();
|
||||
await expect(fs.access(`${activeBase}-wal`)).resolves.toBeUndefined();
|
||||
@@ -230,7 +257,7 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
await fs.utimes(orphanBase, old, old);
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false, true);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
|
||||
await expect(fs.access(orphanBase)).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -251,6 +278,36 @@ 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 });
|
||||
@@ -261,6 +318,6 @@ describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
});
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
requireNodeSqlite,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import {
|
||||
acquireMemoryReindexSwapReadLock,
|
||||
acquireMemoryReindexLock,
|
||||
tryAcquireMemoryReindexLock,
|
||||
type MemoryReindexLockHandle,
|
||||
@@ -21,6 +22,7 @@ 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) {
|
||||
@@ -127,11 +129,18 @@ export function cleanupAgedMemoryReindexTempFiles(dbPath: string, nowMs = Date.n
|
||||
function openConfiguredMemoryDatabaseAtPath(dbPath: string, allowExtension: boolean): DatabaseSync {
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const db = new DatabaseSync(dbPath, { allowExtension });
|
||||
configureMemorySqliteWalMaintenance(db, {
|
||||
busyTimeoutMs: 5000,
|
||||
databasePath: dbPath,
|
||||
});
|
||||
return db;
|
||||
try {
|
||||
configureMemorySqliteWalMaintenance(db, {
|
||||
busyTimeoutMs: 5000,
|
||||
databasePath: dbPath,
|
||||
});
|
||||
return db;
|
||||
} catch (err) {
|
||||
try {
|
||||
db.close();
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type ExistingMemoryDatabaseOpenResult =
|
||||
@@ -186,40 +195,50 @@ export function openMemoryDatabaseAtPath(
|
||||
const dir = path.dirname(dbPath);
|
||||
ensureDir(dir);
|
||||
cleanupAgedMemoryReindexTempFiles(dbPath);
|
||||
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;
|
||||
const swapReadLock = acquireMemoryReindexSwapReadLock(dbPath);
|
||||
try {
|
||||
const lockedExisting = tryOpenExistingMemoryDatabaseAtPath(dbPath, allowExtension);
|
||||
db =
|
||||
lockedExisting.status === "opened"
|
||||
? lockedExisting.db
|
||||
: openConfiguredMemoryDatabaseAtPath(dbPath, allowExtension);
|
||||
} catch (err) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
} catch (err) {
|
||||
try {
|
||||
swapReadLock.release();
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
openLock.release();
|
||||
} catch (err) {
|
||||
closeMemoryDatabase(db);
|
||||
throw err;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function openMemoryReindexTempDatabaseAtPath(
|
||||
@@ -233,4 +252,19 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Memory Core plugin module implements cross-process safe-reindex locking.
|
||||
// The dedicated sibling DB follows custom store paths and relies on SQLite to
|
||||
// release its exclusive transaction automatically after process/container death.
|
||||
// Dedicated sibling DBs follow custom store paths and rely on SQLite to release
|
||||
// shared/exclusive transactions automatically after process/container death.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { requireNodeSqlite } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
|
||||
@@ -12,6 +12,11 @@ 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") {
|
||||
@@ -21,8 +26,7 @@ function isSqliteBusyError(err: unknown): boolean {
|
||||
return /SQLITE_(?:BUSY|LOCKED)|database is locked/i.test(message);
|
||||
}
|
||||
|
||||
function openMemoryReindexLockDatabase(dbPath: string): DatabaseSync {
|
||||
const lockPath = resolveMemoryReindexLockPath(dbPath);
|
||||
function openMemoryLockDatabase(lockPath: string): DatabaseSync {
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const lockDb = new DatabaseSync(lockPath);
|
||||
try {
|
||||
@@ -36,19 +40,7 @@ function openMemoryReindexLockDatabase(dbPath: string): DatabaseSync {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
function createMemoryLockHandle(lockDb: DatabaseSync, label: string): MemoryReindexLockHandle {
|
||||
return {
|
||||
release: () => {
|
||||
let releaseError: unknown;
|
||||
@@ -63,12 +55,35 @@ export function tryAcquireMemoryReindexLock(dbPath: string): MemoryReindexLockHa
|
||||
releaseError ??= err;
|
||||
}
|
||||
if (releaseError) {
|
||||
throw new Error("Failed to release memory reindex lock", { cause: releaseError });
|
||||
throw new Error(`Failed to release ${label}`, { 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) {
|
||||
@@ -81,3 +96,46 @@ 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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
closeMemoryDatabase,
|
||||
openMemoryDatabaseAtPath,
|
||||
openMemoryReindexTempDatabaseAtPath,
|
||||
releaseMemoryDatabaseSwapLock,
|
||||
restoreMemoryDatabaseSwapLock,
|
||||
} from "./manager-db.js";
|
||||
import { isMemoryEmbeddingOperationError } from "./manager-embedding-errors.js";
|
||||
import {
|
||||
@@ -2431,6 +2433,7 @@ 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(
|
||||
@@ -2451,6 +2454,10 @@ 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;
|
||||
@@ -2477,6 +2484,8 @@ export abstract class MemoryManagerSyncOps {
|
||||
this.fts.loadError = undefined;
|
||||
this.ensureSchema();
|
||||
|
||||
originalDbSwapLockReleased = true;
|
||||
releaseMemoryDatabaseSwapLock(originalDb);
|
||||
const nextMeta = await runMemoryAtomicReindex({
|
||||
targetPath: dbPath,
|
||||
tempPath: tempDbPath,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 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 {
|
||||
@@ -10,6 +12,8 @@ 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");
|
||||
}
|
||||
@@ -33,6 +37,138 @@ 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;
|
||||
@@ -86,6 +222,48 @@ 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()
|
||||
@@ -99,7 +277,8 @@ describe("memory manager atomic reindex", () => {
|
||||
renameRetryDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(4);
|
||||
// main (1 retry) + -wal + -shm + -journal.
|
||||
expect(rename).toHaveBeenCalledTimes(5);
|
||||
expect(wait).toHaveBeenCalledTimes(1);
|
||||
expect(wait).toHaveBeenCalledWith(10);
|
||||
});
|
||||
@@ -128,7 +307,8 @@ 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 shm"), { code: "ENOENT" }))
|
||||
.mockRejectedValueOnce(Object.assign(new Error("missing journal"), { code: "ENOENT" }));
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
|
||||
@@ -137,7 +317,8 @@ describe("memory manager atomic reindex", () => {
|
||||
renameRetryDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(3);
|
||||
// main + the three optional sidecars (-wal, -shm, -journal), none retried.
|
||||
expect(rename).toHaveBeenCalledTimes(4);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -202,6 +383,7 @@ 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);
|
||||
@@ -248,13 +430,14 @@ describe("memory manager atomic reindex", () => {
|
||||
const events: string[] = [];
|
||||
let tempClosed = false;
|
||||
const rm: typeof fs.rm = vi.fn(async (filePath) => {
|
||||
events.push(tempClosed ? `rm:${String(filePath)}:closed` : `rm:${String(filePath)}:open`);
|
||||
const entryName = path.basename(String(filePath));
|
||||
events.push(tempClosed ? `rm:${entryName}:closed` : `rm:${entryName}:open`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMemoryAtomicReindex({
|
||||
targetPath: "index.sqlite",
|
||||
tempPath: "index.sqlite.tmp",
|
||||
targetPath: indexPath,
|
||||
tempPath: tempIndexPath,
|
||||
beforeTempCleanup: async () => {
|
||||
events.push("close-temp");
|
||||
tempClosed = true;
|
||||
@@ -273,6 +456,7 @@ 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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -313,8 +497,10 @@ 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;
|
||||
@@ -340,21 +526,90 @@ describe("memory manager atomic reindex", () => {
|
||||
});
|
||||
|
||||
expect(readChunkMarker(indexPath)).toBe("after");
|
||||
expect(rename).toHaveBeenCalledTimes(3);
|
||||
expect(rename).toHaveBeenCalledTimes(4);
|
||||
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 () => {
|
||||
@@ -478,3 +733,26 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { openMemoryDatabaseAtPath } from "./manager-db.js";
|
||||
import { closeMemoryDatabase, 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);
|
||||
db.close();
|
||||
closeMemoryDatabase(db);
|
||||
});
|
||||
|
||||
it("queues targeted session files behind an in-flight sync", async () => {
|
||||
|
||||
@@ -7,6 +7,10 @@ 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 };
|
||||
@@ -237,6 +241,51 @@ 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(
|
||||
|
||||
@@ -387,44 +387,49 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
}
|
||||
this.sources = new Set(effectiveSettings.sources);
|
||||
this.db = this.openDatabase();
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Migrate Hermes plugin module implements apply behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { markMigrationItemSkipped, summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
markMigrationItemError,
|
||||
markMigrationItemSkipped,
|
||||
summarizeMigrationItems,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
archiveMigrationItem,
|
||||
copyMigrationFileItem,
|
||||
@@ -13,6 +18,7 @@ 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";
|
||||
@@ -22,6 +28,60 @@ 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;
|
||||
@@ -55,7 +115,7 @@ export async function applyHermesPlan(params: {
|
||||
} else if (item.kind === "manual") {
|
||||
appliedItem = applyManualItem(item);
|
||||
} else if (item.action === "archive") {
|
||||
appliedItem = await archiveMigrationItem(item, reportDir);
|
||||
appliedItem = await archiveHermesItem(item, reportDir);
|
||||
} else if (item.kind === "auth") {
|
||||
appliedItem = await applyAuthItem(applyCtx, item, targets);
|
||||
} else if (item.kind === "secret") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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";
|
||||
@@ -203,6 +204,75 @@ 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");
|
||||
|
||||
@@ -514,6 +514,82 @@ 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"),
|
||||
@@ -940,6 +1016,44 @@ 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"),
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from "./register.js";
|
||||
|
||||
let workspaceDir: string;
|
||||
let originalOpenClawHome: string | undefined;
|
||||
let originalOpenClawStateDir: string | undefined;
|
||||
|
||||
function cfgWithPolicy(settings: Record<string, unknown> = {}): OpenClawConfig {
|
||||
return {
|
||||
@@ -104,10 +106,37 @@ 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();
|
||||
@@ -249,6 +278,23 @@ 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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -564,6 +610,13 @@ 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",
|
||||
@@ -7805,6 +7858,768 @@ 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
@@ -355,19 +355,49 @@ function policyRuleValueIsValid(metadata: PolicyRuleMetadata, value: unknown): b
|
||||
case "string":
|
||||
return typeof value === "string" && policyStringIsAllowed(metadata, value);
|
||||
case "string-list":
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
typeof entry === "string" &&
|
||||
entry.trim() !== "" &&
|
||||
policyStringIsAllowed(metadata, entry),
|
||||
)
|
||||
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 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 === "") {
|
||||
@@ -506,7 +536,25 @@ function collectScopedPolicyRuleClaims(document: PolicyDocument): readonly Polic
|
||||
}
|
||||
}
|
||||
}
|
||||
return claims;
|
||||
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()];
|
||||
}
|
||||
|
||||
function normalizeSelectorValues(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Policy tests cover policy state plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scanPolicyChannels, scanPolicyTools } from "./policy-state.js";
|
||||
import { scanPolicyChannels, scanPolicyExecApprovals, scanPolicyTools } from "./policy-state.js";
|
||||
|
||||
describe("scanPolicyChannels", () => {
|
||||
it("ignores reserved channel config namespaces", () => {
|
||||
@@ -84,3 +84,123 @@ 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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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",
|
||||
@@ -53,6 +54,7 @@ export type PolicyEvidence = {
|
||||
readonly dataHandling?: readonly PolicyDataHandlingEvidence[];
|
||||
readonly secrets?: readonly PolicySecretEvidence[];
|
||||
readonly authProfiles?: readonly PolicyAuthProfileEvidence[];
|
||||
readonly execApprovals?: readonly PolicyExecApprovalEvidence[];
|
||||
};
|
||||
|
||||
export type PolicyChannelEvidence = {
|
||||
@@ -209,6 +211,21 @@ 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:
|
||||
@@ -302,6 +319,8 @@ export function collectPolicyEvidence(
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
readonly includeAuthProfiles?: boolean;
|
||||
readonly execApprovalsRaw?: string | null;
|
||||
readonly includeExecApprovals?: boolean;
|
||||
},
|
||||
): PolicyEvidence;
|
||||
export function collectPolicyEvidence(
|
||||
@@ -316,6 +335,8 @@ export function collectPolicyEvidence(
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
readonly includeAuthProfiles?: boolean;
|
||||
readonly execApprovalsRaw?: string | null;
|
||||
readonly includeExecApprovals?: boolean;
|
||||
},
|
||||
): Promise<PolicyEvidence>;
|
||||
export function collectPolicyEvidence(
|
||||
@@ -330,6 +351,8 @@ export function collectPolicyEvidence(
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
readonly includeAuthProfiles?: boolean;
|
||||
readonly execApprovalsRaw?: string | null;
|
||||
readonly includeExecApprovals?: boolean;
|
||||
} = {},
|
||||
): PolicyEvidence | Promise<PolicyEvidence> {
|
||||
const evidence = {
|
||||
@@ -352,6 +375,14 @@ 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;
|
||||
@@ -359,6 +390,278 @@ 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))
|
||||
|
||||
@@ -81,6 +81,7 @@ export {
|
||||
} from "./src/scenario-catalog.js";
|
||||
export { createQaSelfCheckScenario } from "./src/self-check-scenario.js";
|
||||
export {
|
||||
isQaSelfCheckSuccessful,
|
||||
type QaSelfCheckResult,
|
||||
resolveQaSelfCheckOutputPath,
|
||||
runQaSelfCheckAgainstState,
|
||||
|
||||
@@ -12,16 +12,24 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chat-adapter/state-memory": "^4.30.0",
|
||||
"@chat-adapter/telegram": "^4.30.0",
|
||||
"@openclaw/discord": "workspace:*",
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@openclaw/slack": "workspace:*",
|
||||
"@openclaw/whatsapp": "workspace:*",
|
||||
"chat": "^4.30.0",
|
||||
"crabline": "github:openclaw/crabline#1ee8aa3a7e83af8f9bc6579e43688a646c899769",
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"crabline": "*",
|
||||
"openclaw": ">=2026.6.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"crabline": {
|
||||
"optional": true
|
||||
},
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
|
||||
@@ -284,6 +284,13 @@ 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(),
|
||||
});
|
||||
@@ -332,6 +339,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: process.cwd(),
|
||||
outputDir: path.join(process.cwd(), ".artifacts", "qa-e2e", "scenario-test"),
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: undefined,
|
||||
fastMode: undefined,
|
||||
@@ -434,10 +443,17 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa-e2e/smoke-ci"),
|
||||
transportId: "qa-channel",
|
||||
channelDriver: "crabline",
|
||||
providerMode: "mock-openai",
|
||||
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");
|
||||
@@ -485,6 +501,20 @@ describe("qa cli runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes non-Crabline profile channel drivers as declarative suite metadata", 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.channelDriver).toBe("live");
|
||||
expect(suiteArgs.channelDriverSelection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects qa profile runs that do not match taxonomy categories", async () => {
|
||||
await expect(
|
||||
runQaProfileCommand({
|
||||
@@ -524,6 +554,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: path.resolve("/tmp/openclaw-repo", ".artifacts/qa/frontier"),
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
providerMode: "live-frontier",
|
||||
primaryModel: "openai/gpt-5.5",
|
||||
alternateModel: "anthropic/claude-sonnet-4-6",
|
||||
@@ -533,6 +565,48 @@ describe("qa cli runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Crabline channel-driver channel from selected scenario execution", async () => {
|
||||
await runQaSuiteCommand({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
outputDir: ".artifacts/qa/multipass-telegram",
|
||||
providerMode: "mock-openai",
|
||||
channelDriver: "crabline",
|
||||
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",
|
||||
channelDriver: "crabline",
|
||||
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",
|
||||
@@ -545,6 +619,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: undefined,
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: undefined,
|
||||
alternateModel: undefined,
|
||||
@@ -566,6 +642,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: undefined,
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: undefined,
|
||||
alternateModel: undefined,
|
||||
@@ -616,6 +694,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: undefined,
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: undefined,
|
||||
alternateModel: undefined,
|
||||
@@ -2011,6 +2091,8 @@ describe("qa cli runtime", () => {
|
||||
repoRoot: path.resolve("/tmp/openclaw-repo"),
|
||||
outputDir: undefined,
|
||||
transportId: "qa-channel",
|
||||
channelDriver: undefined,
|
||||
channelDriverSelection: undefined,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "openai/gpt-5.5",
|
||||
alternateModel: "anthropic/claude-opus-4-8",
|
||||
@@ -2154,6 +2236,33 @@ 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",
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
renderQaCoverageMarkdownReport,
|
||||
renderQaScenarioMatchesMarkdownReport,
|
||||
} from "./coverage-report.js";
|
||||
import {
|
||||
QA_CRABLINE_DEFAULT_CHANNEL,
|
||||
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";
|
||||
@@ -70,12 +74,15 @@ import {
|
||||
import { resolveQaScenarioPackScenarioIds } from "./scenario-packs.js";
|
||||
import { attachQaProfileScorecardEvidenceToFile } from "./scorecard-evidence.js";
|
||||
import {
|
||||
qaScorecardChannelDriverSchema,
|
||||
readQaScorecardTaxonomyReport,
|
||||
type QaScorecardCategoryCoverageReport,
|
||||
type QaScorecardChannelDriver,
|
||||
type QaScorecardEvidenceMode,
|
||||
} 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 { resolveQaSuiteScenarioChannel, scenarioMatchesQaProviderLane } from "./suite-planning.js";
|
||||
import { readQaSuiteFailedOrSkippedScenarioCountFromFile } from "./suite-summary.js";
|
||||
import {
|
||||
buildTokenEfficiencyReport,
|
||||
@@ -130,6 +137,8 @@ export type QaProfileCommandOptions = QaScenarioRunCommandOptions & {
|
||||
};
|
||||
|
||||
export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
|
||||
channelDriver?: string;
|
||||
channel?: string;
|
||||
runner?: string;
|
||||
thinking?: string;
|
||||
cliAuthMode?: string;
|
||||
@@ -146,6 +155,20 @@ export type QaSuiteCommandOptions = QaScenarioRunCommandOptions & {
|
||||
runtimeParityTier?: string[];
|
||||
};
|
||||
|
||||
function normalizeQaSuiteChannelDriver(
|
||||
input?: string | null,
|
||||
): QaScorecardChannelDriver | undefined {
|
||||
const normalized = input?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = qaScorecardChannelDriverSchema.safeParse(normalized);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
throw new Error(`--channel-driver must be one of qa-channel, crabline, or live, got "${input}".`);
|
||||
}
|
||||
|
||||
function resolveQaManualLaneModels(opts: {
|
||||
providerMode: QaProviderMode;
|
||||
primaryModel?: string;
|
||||
@@ -617,6 +640,9 @@ 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();
|
||||
}
|
||||
@@ -630,6 +656,10 @@ 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,
|
||||
@@ -680,6 +710,7 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
|
||||
scenarioIds: scenarios.map((scenario) => scenario.id),
|
||||
concurrency: opts.concurrency,
|
||||
allowFailures: opts.allowFailures,
|
||||
channelDriver: profileReport.channelDriver,
|
||||
});
|
||||
evidencePath =
|
||||
suiteResult && "evidencePath" in suiteResult ? suiteResult.evidencePath : undefined;
|
||||
@@ -700,6 +731,30 @@ export async function runQaProfileCommand(opts: QaProfileCommandOptions) {
|
||||
process.stdout.write(`QA profile scorecard: ${evidencePath}\n`);
|
||||
}
|
||||
|
||||
function selectQaScenarioDefinitionsForChannelResolution(params: {
|
||||
scenarioIds: string[];
|
||||
providerMode: QaProviderMode;
|
||||
primaryModel: string;
|
||||
claudeCliAuthMode?: QaCliBackendAuthMode;
|
||||
}) {
|
||||
const scenarios = readQaScenarioPack().scenarios;
|
||||
if (params.scenarioIds.length > 0) {
|
||||
const scenarioById = new Map(scenarios.map((scenario) => [scenario.id, scenario]));
|
||||
return params.scenarioIds.flatMap((scenarioId) => {
|
||||
const scenario = scenarioById.get(scenarioId);
|
||||
return scenario ? [scenario] : [];
|
||||
});
|
||||
}
|
||||
return scenarios.filter((scenario) =>
|
||||
scenarioMatchesQaProviderLane({
|
||||
scenario,
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
claudeCliAuthMode: params.claudeCliAuthMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeQaRunProfile(value: string, profileIds: readonly string[]) {
|
||||
if (profileIds.length === 0) {
|
||||
throw new Error("taxonomy.yaml does not define QA run profiles.");
|
||||
@@ -781,12 +836,39 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
|
||||
const claudeCliAuthMode = parseQaCliBackendAuthMode(opts.cliAuthMode);
|
||||
const primaryModel = normalizeQaOptionalModelRef(opts.primaryModel);
|
||||
const alternateModel = normalizeQaOptionalModelRef(opts.alternateModel);
|
||||
const channelDriver = normalizeQaSuiteChannelDriver(opts.channelDriver);
|
||||
if (opts.channel?.trim() && channelDriver !== "crabline") {
|
||||
throw new Error("--channel requires --channel-driver crabline.");
|
||||
}
|
||||
const selectedScenarioChannel =
|
||||
channelDriver === "crabline"
|
||||
? resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: QA_CRABLINE_DEFAULT_CHANNEL,
|
||||
explicitChannel: opts.channel,
|
||||
scenarios: selectQaScenarioDefinitionsForChannelResolution({
|
||||
scenarioIds,
|
||||
providerMode,
|
||||
primaryModel: primaryModel ?? defaultQaModelForMode(providerMode),
|
||||
claudeCliAuthMode,
|
||||
}),
|
||||
})
|
||||
: opts.channel;
|
||||
const channelDriverSelection =
|
||||
channelDriver === "crabline"
|
||||
? await resolveQaCrablineChannelDriverSelection({
|
||||
channel: selectedScenarioChannel,
|
||||
channelDriver,
|
||||
})
|
||||
: undefined;
|
||||
if (runner !== "host" && runner !== "multipass") {
|
||||
throw new Error(`--runner must be one of host or multipass, got "${opts.runner}".`);
|
||||
}
|
||||
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 ||
|
||||
@@ -855,6 +937,8 @@ export async function runQaSuiteCommand(opts: QaSuiteCommandOptions) {
|
||||
outputDir: resolveRepoRelativeOutputDir(repoRoot, opts.outputDir),
|
||||
evidenceMode: opts.evidenceMode,
|
||||
transportId,
|
||||
channelDriver,
|
||||
channelDriverSelection,
|
||||
...(opts.providerMode !== undefined ? { providerMode } : {}),
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
|
||||
@@ -62,6 +62,8 @@ 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"];
|
||||
@@ -453,6 +455,11 @@ 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 override for --channel-driver; defaults to scenario/default",
|
||||
)
|
||||
.option("--provider-mode <mode>", formatQaProviderModeHelp())
|
||||
.option("--model <ref>", "Primary provider/model ref")
|
||||
.option("--alt-model <ref>", "Alternate provider/model ref")
|
||||
@@ -504,6 +511,8 @@ 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -31,12 +32,18 @@ function testMaturityTaxonomy(params?: {
|
||||
{
|
||||
id: "smoke-ci",
|
||||
description: "Test smoke profile.",
|
||||
includeAllCategories: false,
|
||||
channelDriver: "qa-channel" as const,
|
||||
categoryIds: [],
|
||||
},
|
||||
{
|
||||
id: "release",
|
||||
description: "Test release profile.",
|
||||
categoryIds: [...(params?.profileCategoryIds ?? [categoryId])],
|
||||
includeAllCategories: params?.includeAllCategories ?? false,
|
||||
channelDriver: "qa-channel" as const,
|
||||
categoryIds: [
|
||||
...(params?.includeAllCategories ? [] : (params?.profileCategoryIds ?? [categoryId])),
|
||||
],
|
||||
},
|
||||
],
|
||||
surfaces: [
|
||||
@@ -114,8 +121,22 @@ describe("qa coverage report", () => {
|
||||
"whatsapp",
|
||||
]);
|
||||
expect(inventory.scorecardTaxonomy.profileCount).toBe(2);
|
||||
expect(
|
||||
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "smoke-ci"),
|
||||
).toMatchObject({
|
||||
channelDriver: "crabline",
|
||||
evidenceMode: "slim",
|
||||
});
|
||||
expect(
|
||||
inventory.scorecardTaxonomy.profiles.find((profile) => profile.id === "release"),
|
||||
).toMatchObject({
|
||||
channelDriver: "live",
|
||||
});
|
||||
expect(inventory.scorecardTaxonomy.categoryCount).toBeGreaterThan(200);
|
||||
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBe(15);
|
||||
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeGreaterThan(0);
|
||||
expect(inventory.scorecardTaxonomy.requiredCategoryCount).toBeLessThanOrEqual(
|
||||
inventory.scorecardTaxonomy.categoryCount,
|
||||
);
|
||||
expect(inventory.scorecardTaxonomy.requiredFeatureCount).toBeGreaterThan(0);
|
||||
expect(inventory.scorecardTaxonomy.fulfilledFeatureCount).toBeGreaterThan(0);
|
||||
expect(inventory.scorecardTaxonomy.taxonomyFulfillmentPercent).toBeGreaterThan(0);
|
||||
@@ -124,30 +145,15 @@ describe("qa coverage report", () => {
|
||||
expect(inventory.scorecardTaxonomy.unknownCoverageIdCount).toBe(0);
|
||||
expect(inventory.scorecardTaxonomy.validationIssues.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
inventory.scorecardTaxonomy.validationIssues.every(
|
||||
inventory.scorecardTaxonomy.validationIssues.some((issue) =>
|
||||
issue.code.endsWith("not-found"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
inventory.scorecardTaxonomy.validationIssues.some(
|
||||
(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,
|
||||
@@ -349,6 +355,21 @@ 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(),
|
||||
|
||||
123
extensions/qa-lab/src/crabline-channel-driver.test.ts
Normal file
123
extensions/qa-lab/src/crabline-channel-driver.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Qa Lab tests cover Crabline channel-driver metadata behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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", async () => {
|
||||
await expect(resolveQaCrablineChannelDriverSelection({})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the Telegram SDK-backed channel driver", async () => {
|
||||
const selection = await resolveQaCrablineChannelDriverSelection({
|
||||
channel: "telegram",
|
||||
channelDriver: "crabline",
|
||||
});
|
||||
|
||||
expect(selection).toEqual({
|
||||
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
|
||||
channel: "telegram",
|
||||
channelDriver: "crabline",
|
||||
smokeArtifactPath: "crabline-channel-smoke.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts channels reported ready by Crabline", async () => {
|
||||
await expect(
|
||||
resolveQaCrablineChannelDriverSelection({
|
||||
channel: "slack",
|
||||
channelDriver: "crabline",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
channel: "slack",
|
||||
channelDriver: "crabline",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs Crabline's Chat SDK provider doctor through the package CLI", async () => {
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-driver-"));
|
||||
try {
|
||||
const result = await runQaCrablineChannelDriverSmoke(
|
||||
{
|
||||
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
|
||||
channel: "telegram",
|
||||
channelDriver: "crabline",
|
||||
smokeArtifactPath: "crabline-channel-smoke.json",
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
TELEGRAM_BOT_TOKEN: "telegram-token",
|
||||
},
|
||||
outputDir,
|
||||
},
|
||||
);
|
||||
expect(result.capabilityReport).toMatchObject({
|
||||
result: {
|
||||
configured: [expect.objectContaining({ adapter: "telegram", platform: "telegram" })],
|
||||
},
|
||||
});
|
||||
expect(result.smoke).toMatchObject({
|
||||
result: {
|
||||
findings: [],
|
||||
ok: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails Crabline's Chat SDK provider doctor when required env is unavailable", async () => {
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-driver-"));
|
||||
try {
|
||||
await expect(
|
||||
runQaCrablineChannelDriverSmoke(
|
||||
{
|
||||
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
|
||||
channel: "telegram",
|
||||
channelDriver: "crabline",
|
||||
smokeArtifactPath: "crabline-channel-smoke.json",
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
TELEGRAM_BOT_TOKEN: "",
|
||||
},
|
||||
outputDir,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("provider telegram missing telegram.botToken or TELEGRAM_BOT_TOKEN");
|
||||
} finally {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to Telegram and rejects channels not reported ready by Crabline", async () => {
|
||||
await expect(
|
||||
resolveQaCrablineChannelDriverSelection({ channelDriver: "crabline" }),
|
||||
).resolves.toEqual({
|
||||
capabilityMatrixPath: "crabline-channel-capability-matrix.json",
|
||||
channel: "telegram",
|
||||
channelDriver: "crabline",
|
||||
smokeArtifactPath: "crabline-channel-smoke.json",
|
||||
});
|
||||
await expect(
|
||||
resolveQaCrablineChannelDriverSelection({
|
||||
channel: "signal",
|
||||
channelDriver: "crabline",
|
||||
}),
|
||||
).rejects.toThrow("--channel must be one of");
|
||||
});
|
||||
|
||||
it("rejects channel identity without a channel driver", async () => {
|
||||
await expect(resolveQaCrablineChannelDriverSelection({ channel: "telegram" })).rejects.toThrow(
|
||||
"--channel requires --channel-driver crabline",
|
||||
);
|
||||
});
|
||||
});
|
||||
251
extensions/qa-lab/src/crabline-channel-driver.ts
Normal file
251
extensions/qa-lab/src/crabline-channel-driver.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// Qa Lab plugin module models SDK-backed Crabline channel-driver metadata.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type QaChannelDriverId = "crabline";
|
||||
export type QaCrablineChannelId = string;
|
||||
|
||||
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";
|
||||
export const QA_CRABLINE_MANIFEST_PATH = "crabline-smoke.json";
|
||||
export const QA_CRABLINE_DEFAULT_CHANNEL = "telegram";
|
||||
|
||||
let supportedCrablineChannelsPromise: Promise<QaCrablineChannelId[]> | undefined;
|
||||
|
||||
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 async function normalizeQaCrablineChannel(
|
||||
input?: string | null,
|
||||
): Promise<QaCrablineChannelId> {
|
||||
const normalized = input?.trim().toLowerCase() || QA_CRABLINE_DEFAULT_CHANNEL;
|
||||
const supportedChannels = await listSupportedCrablineChannels();
|
||||
if (supportedChannels.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`--channel must be one of ${supportedChannels.join(", ")} for --channel-driver crabline, got "${input}".`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveQaCrablineChannelDriverSelection(params: {
|
||||
channel?: string | null;
|
||||
channelDriver?: string | null;
|
||||
}): Promise<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 = await normalizeQaCrablineChannel(params.channel);
|
||||
return {
|
||||
channel,
|
||||
channelDriver,
|
||||
capabilityMatrixPath: QA_CRABLINE_CHANNEL_CAPABILITY_MATRIX_PATH,
|
||||
smokeArtifactPath: QA_CRABLINE_CHANNEL_SMOKE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
type CrablineCommandResult = {
|
||||
command: string[];
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
export type QaCrablineChannelDriverSmokeResult = {
|
||||
capabilityReport: unknown;
|
||||
manifestPath: string;
|
||||
smoke: unknown;
|
||||
};
|
||||
|
||||
function resolveCrablineBinPath() {
|
||||
const indexPath = fileURLToPath(import.meta.resolve("crabline"));
|
||||
return path.join(path.dirname(indexPath), "bin", "crabline.js");
|
||||
}
|
||||
|
||||
function createCrablineCatalogManifest() {
|
||||
return {
|
||||
configVersion: 1,
|
||||
fixtures: [],
|
||||
providers: {},
|
||||
userName: "openclaw-qa",
|
||||
};
|
||||
}
|
||||
|
||||
function createCrablineManifest(selection: QaCrablineChannelDriverSelection) {
|
||||
return {
|
||||
configVersion: 1,
|
||||
fixtures: [],
|
||||
providers: {
|
||||
[selection.channel]: {
|
||||
adapter: selection.channel,
|
||||
},
|
||||
},
|
||||
userName: "openclaw-qa",
|
||||
};
|
||||
}
|
||||
|
||||
async function runCrablineJsonCommand(params: {
|
||||
args: readonly string[];
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ json: unknown; result: CrablineCommandResult }> {
|
||||
const command = [resolveCrablineBinPath(), "--json", ...params.args];
|
||||
const displayCommand = ["node", "crabline", "--json", ...params.args];
|
||||
try {
|
||||
const result = await execFileAsync(process.execPath, command, {
|
||||
cwd: params.cwd,
|
||||
encoding: "utf8",
|
||||
env: params.env ?? process.env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const stdout = result.stdout.toString();
|
||||
return {
|
||||
json: JSON.parse(stdout),
|
||||
result: {
|
||||
command: displayCommand,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const childError = error as Error & {
|
||||
code?: number | string;
|
||||
stderr?: string | Buffer;
|
||||
stdout?: string | Buffer;
|
||||
};
|
||||
const stdout = childError.stdout?.toString() ?? "";
|
||||
const stderr = childError.stderr?.toString() ?? "";
|
||||
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
`Crabline command failed (${displayCommand.join(" ")}): ${details || childError.message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readCrablineSupportedChannels(payload: unknown): QaCrablineChannelId[] {
|
||||
const support = (payload as { support?: unknown }).support;
|
||||
if (!Array.isArray(support)) {
|
||||
throw new Error("Crabline providers output did not include a support catalog.");
|
||||
}
|
||||
const channels = support
|
||||
.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return [];
|
||||
}
|
||||
const candidate = entry as { platform?: unknown; status?: unknown };
|
||||
return candidate.status === "ready" &&
|
||||
typeof candidate.platform === "string" &&
|
||||
candidate.platform !== "loopback"
|
||||
? [candidate.platform]
|
||||
: [];
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
return [...new Set(channels)];
|
||||
}
|
||||
|
||||
async function readSupportedCrablineChannels(): Promise<QaCrablineChannelId[]> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-crabline-catalog-"));
|
||||
try {
|
||||
const manifestPath = path.join(tempDir, "crabline-catalog.json");
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(createCrablineCatalogManifest(), null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const providers = await runCrablineJsonCommand({
|
||||
args: ["--config", manifestPath, "providers"],
|
||||
cwd: tempDir,
|
||||
});
|
||||
const supportedChannels = readCrablineSupportedChannels(providers.json);
|
||||
if (supportedChannels.length === 0) {
|
||||
throw new Error("Crabline did not report any ready channel providers.");
|
||||
}
|
||||
return supportedChannels;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function listSupportedCrablineChannels(): Promise<QaCrablineChannelId[]> {
|
||||
supportedCrablineChannelsPromise ??= readSupportedCrablineChannels();
|
||||
return await supportedCrablineChannelsPromise;
|
||||
}
|
||||
|
||||
export async function runQaCrablineChannelDriverSmoke(
|
||||
selection: QaCrablineChannelDriverSelection,
|
||||
params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputDir: string;
|
||||
},
|
||||
): Promise<QaCrablineChannelDriverSmokeResult> {
|
||||
const manifestPath = path.join(params.outputDir, QA_CRABLINE_MANIFEST_PATH);
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(createCrablineManifest(selection), null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const providers = await runCrablineJsonCommand({
|
||||
args: ["--config", manifestPath, "providers"],
|
||||
cwd: params.outputDir,
|
||||
env: params.env,
|
||||
});
|
||||
const doctor = await runCrablineJsonCommand({
|
||||
args: ["--config", manifestPath, "doctor"],
|
||||
cwd: params.outputDir,
|
||||
env: params.env,
|
||||
});
|
||||
return {
|
||||
capabilityReport: {
|
||||
command: providers.result.command,
|
||||
manifestPath: path.basename(manifestPath),
|
||||
result: providers.json,
|
||||
},
|
||||
manifestPath: path.basename(manifestPath),
|
||||
smoke: {
|
||||
command: doctor.result.command,
|
||||
manifestPath: path.basename(manifestPath),
|
||||
result: doctor.json,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 Chat SDK messaging-provider path; it is independent of the Canonical Multipass VM runner.",
|
||||
];
|
||||
}
|
||||
@@ -255,6 +255,67 @@ 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([
|
||||
{
|
||||
@@ -692,6 +753,94 @@ 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) {
|
||||
@@ -732,6 +881,13 @@ 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",
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
} from "../shared/credential-lease.runtime.js";
|
||||
import {
|
||||
appendQaLiveLaneIssue as appendLiveLaneIssue,
|
||||
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
|
||||
redactQaLiveLaneDetails,
|
||||
redactQaLiveLaneIssues,
|
||||
} from "../shared/live-artifacts.js";
|
||||
@@ -1895,6 +1894,7 @@ async function waitForScenarioObservedMessage(
|
||||
label: string;
|
||||
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
|
||||
}>;
|
||||
expectedSender?: (message: WhatsAppQaDriverObservedMessage) => boolean;
|
||||
match: (message: WhatsAppQaDriverObservedMessage) => boolean;
|
||||
observedAfter?: Date;
|
||||
timeoutMs?: number;
|
||||
@@ -1906,7 +1906,8 @@ async function waitForScenarioObservedMessage(
|
||||
observedAfter: params.observedAfter,
|
||||
timeoutMs: params.timeoutMs ?? 45_000,
|
||||
match: (candidate) =>
|
||||
candidate.fromPhoneE164 === context.sutPhoneE164 && params.match(candidate),
|
||||
(params.expectedSender?.(candidate) ?? candidate.fromPhoneE164 === context.sutPhoneE164) &&
|
||||
params.match(candidate),
|
||||
});
|
||||
} catch (error) {
|
||||
if (/\btimed out waiting for WhatsApp QA driver message\b/iu.test(formatErrorMessage(error))) {
|
||||
@@ -2522,6 +2523,8 @@ 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) {
|
||||
@@ -2709,26 +2712,19 @@ async function runWhatsAppScenario(params: {
|
||||
details: "no reply",
|
||||
};
|
||||
}
|
||||
const reply = await params.driver.waitForMessage({
|
||||
const reply = await waitForScenarioObservedMessage(scenarioContext, {
|
||||
observedAfter: requestStartedAt,
|
||||
timeoutMs: params.scenario.timeoutMs,
|
||||
match: (message) =>
|
||||
(scenarioRun.target === "group"
|
||||
expectedSender: (message) =>
|
||||
scenarioRun.target === "group"
|
||||
? message.fromJid === params.groupJid
|
||||
: message.fromPhoneE164 === params.sutPhoneE164) &&
|
||||
messageMatches(message as WhatsAppObservedMessage, scenarioRun.matchText),
|
||||
: message.fromPhoneE164 === params.sutPhoneE164,
|
||||
match: (message) => 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(observed.messageId ? [observed.messageId] : []),
|
||||
alreadyRecordedMessageIds: new Set(reply.messageId ? [reply.messageId] : []),
|
||||
context: scenarioContext,
|
||||
observedAfter: requestStartedAt,
|
||||
run: scenarioRun,
|
||||
@@ -2754,8 +2750,13 @@ async function runWhatsAppScenario(params: {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
preservedGatewayDebug = true;
|
||||
await gatewayHarness.stop({ preserveToDir: params.gatewayDebugDirPath }).catch(() => {});
|
||||
try {
|
||||
await gatewayHarness.stop({ preserveToDir: params.gatewayDebugDirPath });
|
||||
preservedGatewayDebug = true;
|
||||
params.onGatewayDebugPreserved?.();
|
||||
} catch (preserveError) {
|
||||
params.onGatewayDebugPreserveFailure?.(preserveError);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!preservedGatewayDebug) {
|
||||
@@ -2948,6 +2949,43 @@ 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;
|
||||
@@ -3074,6 +3112,8 @@ export async function runWhatsAppQaLive(params: {
|
||||
}
|
||||
let driverAttempt = 1;
|
||||
while (true) {
|
||||
let scenarioGatewayDebugPreserved = false;
|
||||
const scenarioGatewayDebugPreserveFailures: unknown[] = [];
|
||||
try {
|
||||
const result = await runWhatsAppScenario({
|
||||
driver: activeDriver,
|
||||
@@ -3090,6 +3130,12 @@ export async function runWhatsAppQaLive(params: {
|
||||
sutAccountId,
|
||||
sutAuthDir,
|
||||
sutPhoneE164: runtimeEnv.sutPhoneE164,
|
||||
onGatewayDebugPreserved: () => {
|
||||
scenarioGatewayDebugPreserved = true;
|
||||
},
|
||||
onGatewayDebugPreserveFailure: (error) => {
|
||||
scenarioGatewayDebugPreserveFailures.push(error);
|
||||
},
|
||||
});
|
||||
const recordedResult =
|
||||
driverAttempt > 1
|
||||
@@ -3126,7 +3172,12 @@ export async function runWhatsAppQaLive(params: {
|
||||
closeDriverSession = () => activeDriver.close();
|
||||
continue;
|
||||
}
|
||||
preservedGatewayDebugArtifacts = true;
|
||||
if (scenarioGatewayDebugPreserved) {
|
||||
preservedGatewayDebugArtifacts = true;
|
||||
}
|
||||
for (const preserveError of scenarioGatewayDebugPreserveFailures) {
|
||||
appendLiveLaneIssue(cleanupIssues, "gateway debug preserve failed", preserveError);
|
||||
}
|
||||
const result: WhatsAppQaScenarioResult = {
|
||||
id: scenario.id,
|
||||
title: scenario.title,
|
||||
@@ -3156,17 +3207,7 @@ export async function runWhatsAppQaLive(params: {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupIssues.push(
|
||||
buildLiveLaneArtifactsError({
|
||||
heading: "WhatsApp QA failed before scenario completion.",
|
||||
details: [formatErrorMessage(error)],
|
||||
artifacts: {
|
||||
gatewayDebug: gatewayDebugDirPath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
preservedGatewayDebugArtifacts = true;
|
||||
await fs.mkdir(gatewayDebugDirPath, { recursive: true }).catch(() => {});
|
||||
appendLiveLaneIssue(cleanupIssues, "WhatsApp QA failed before scenario completion", error);
|
||||
appendPreScenarioFailureResults({
|
||||
details: formatErrorMessage(error),
|
||||
scenarioResults,
|
||||
@@ -3206,19 +3247,20 @@ 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 publishedCleanupIssues = redactPublicMetadata
|
||||
? redactQaLiveLaneIssues(cleanupIssues)
|
||||
: cleanupIssues;
|
||||
const publishedScenarioResults = redactPublicMetadata
|
||||
? redactWhatsAppQaScenarioResults(scenarioResults)
|
||||
: scenarioResults;
|
||||
const publishedRunView = await buildPublishedWhatsAppQaRunView({
|
||||
cleanupIssues,
|
||||
gatewayDebugDirPath,
|
||||
preservedGatewayDebugArtifacts,
|
||||
redactMetadata: redactPublicMetadata,
|
||||
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: publishedScenarioResults.map(({ standardId, ...check }) => ({
|
||||
checks: publishedRunView.scenarioResults.map(({ standardId, ...check }) => ({
|
||||
...check,
|
||||
coverageIds: standardId ? [`channels.whatsapp.${standardId}`] : undefined,
|
||||
})),
|
||||
@@ -3244,13 +3286,13 @@ export async function runWhatsAppQaLive(params: {
|
||||
await fs.writeFile(
|
||||
reportPath,
|
||||
`${renderWhatsAppQaMarkdown({
|
||||
cleanupIssues: publishedCleanupIssues,
|
||||
cleanupIssues: publishedRunView.cleanupIssues,
|
||||
credentialFingerprint,
|
||||
credentialSource: credentialLease?.source ?? requestedCredentialSource,
|
||||
finishedAt,
|
||||
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
|
||||
gatewayDebugDirPath: publishedRunView.gatewayDebugDirPath,
|
||||
redactMetadata: redactPublicMetadata,
|
||||
scenarios: publishedScenarioResults,
|
||||
scenarios: publishedRunView.scenarioResults,
|
||||
startedAt,
|
||||
sutPhoneE164: runtimeEnv?.sutPhoneE164,
|
||||
})}\n`,
|
||||
@@ -3260,7 +3302,7 @@ export async function runWhatsAppQaLive(params: {
|
||||
reportPath,
|
||||
summaryPath,
|
||||
observedMessagesPath,
|
||||
gatewayDebugDirPath: preservedGatewayDebugArtifacts ? gatewayDebugDirPath : undefined,
|
||||
gatewayDebugDirPath: publishedRunView.gatewayDebugDirPath,
|
||||
scenarios: scenarioResults,
|
||||
};
|
||||
}
|
||||
@@ -3268,6 +3310,7 @@ export async function runWhatsAppQaLive(params: {
|
||||
export const testing = {
|
||||
assertSafeArchiveEntries,
|
||||
appendPreScenarioFailureResults,
|
||||
buildPublishedWhatsAppQaRunView,
|
||||
buildWhatsAppQaConfig,
|
||||
callWhatsAppGatewayMessageAction,
|
||||
callWhatsAppGatewayPoll,
|
||||
@@ -3281,11 +3324,13 @@ export const testing = {
|
||||
formatWhatsAppScenarioProgressLine,
|
||||
fingerprintWhatsAppCredentialId: fingerprintQaCredentialId,
|
||||
formatWhatsAppScenarioWaitDiagnostics,
|
||||
hasWhatsAppGatewayDebugArtifacts,
|
||||
isTransientWhatsAppQaDriverError,
|
||||
matchesWhatsAppApprovalResolvedText,
|
||||
parseWhatsAppQaCredentialPayload,
|
||||
renderWhatsAppQaMarkdown,
|
||||
runWhatsAppStructuredInboundChecks,
|
||||
waitForScenarioObservedMessage,
|
||||
redactWhatsAppQaScenarioResults,
|
||||
resolveWhatsAppQaMessageTargets,
|
||||
resolveWhatsAppQaRuntimeEnv,
|
||||
|
||||
@@ -58,14 +58,23 @@ const qaScenarioRepoRefSchema = z
|
||||
message: "repo refs must not be absolute or contain parent-directory segments",
|
||||
});
|
||||
|
||||
const qaScenarioChannelSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
|
||||
message: "scenario execution channel ids must use lowercase dotted or dashed tokens",
|
||||
});
|
||||
|
||||
const qaFlowScenarioExecutionSchema = z.object({
|
||||
kind: z.literal("flow").default("flow"),
|
||||
summary: z.string().trim().min(1).optional(),
|
||||
channel: qaScenarioChannelSchema.optional(),
|
||||
config: qaScenarioConfigSchema.optional(),
|
||||
});
|
||||
|
||||
const qaTestFileScenarioExecutionBaseSchema = z.object({
|
||||
summary: z.string().trim().min(1).optional(),
|
||||
channel: qaScenarioChannelSchema.optional(),
|
||||
path: qaScenarioRepoRefSchema,
|
||||
config: qaScenarioConfigSchema.optional(),
|
||||
});
|
||||
|
||||
@@ -20,11 +20,14 @@ 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"),
|
||||
categoryIds: z.array(qaScorecardIdSchema).default([]),
|
||||
});
|
||||
|
||||
@@ -67,6 +70,28 @@ 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.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)) {
|
||||
@@ -84,6 +109,7 @@ 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>;
|
||||
|
||||
@@ -129,6 +155,7 @@ export type QaScorecardCategoryCoverageReport = {
|
||||
export type QaScorecardProfileReport = {
|
||||
id: string;
|
||||
evidenceMode: QaScorecardEvidenceMode;
|
||||
channelDriver: QaScorecardChannelDriver;
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
@@ -339,12 +366,14 @@ export function readQaScorecardFeatureCoverageByCategory(repoRoot?: string) {
|
||||
export function readQaScorecardProfileOptions(profileId: string | undefined, repoRoot?: string) {
|
||||
const profile = profileId?.trim();
|
||||
if (!profile) {
|
||||
return { evidenceMode: "full" as const };
|
||||
return { evidenceMode: "full" as const, channelDriver: "qa-channel" as const };
|
||||
}
|
||||
const profileOptions = readQaMaturityTaxonomy(repoRoot)?.profiles.find(
|
||||
(entry) => entry.id === profile,
|
||||
);
|
||||
return {
|
||||
evidenceMode:
|
||||
readQaMaturityTaxonomy(repoRoot)?.profiles.find((entry) => entry.id === profile)
|
||||
?.evidenceMode ?? "full",
|
||||
evidenceMode: profileOptions?.evidenceMode ?? "full",
|
||||
channelDriver: profileOptions?.channelDriver ?? "qa-channel",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,7 +495,10 @@ export function buildQaScorecardTaxonomyReport(params: {
|
||||
const profiles =
|
||||
params.taxonomy?.profiles.map((profile) => {
|
||||
const validCategoryIds: string[] = [];
|
||||
for (const categoryId of profile.categoryIds) {
|
||||
const selectedCategoryIds = profile.includeAllCategories
|
||||
? [...maturityRefs.categories.keys()]
|
||||
: profile.categoryIds;
|
||||
for (const categoryId of selectedCategoryIds) {
|
||||
if (!maturityRefs.categories.has(categoryId)) {
|
||||
issues.push({
|
||||
code: "profile-category-ref-not-found",
|
||||
@@ -484,6 +516,7 @@ export function buildQaScorecardTaxonomyReport(params: {
|
||||
return {
|
||||
id: profile.id,
|
||||
evidenceMode: profile.evidenceMode ?? "full",
|
||||
channelDriver: profile.channelDriver,
|
||||
categoryIds: validCategoryIds,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
@@ -1,7 +1,47 @@
|
||||
// Qa Lab tests cover self check plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQaSelfCheckOutputPath } from "./self-check.js";
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveQaSelfCheckOutputPath", () => {
|
||||
it("keeps explicit output paths untouched", () => {
|
||||
|
||||
@@ -15,6 +15,13 @@ 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;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
collectQaSuitePluginIds,
|
||||
mapQaSuiteWithConcurrency,
|
||||
normalizeQaSuiteConcurrency,
|
||||
resolveQaSuiteScenarioChannel,
|
||||
resolveQaSuiteWorkerStartStaggerMs,
|
||||
resolveQaSuiteOutputDir,
|
||||
scenarioRequiresControlUi,
|
||||
@@ -241,6 +242,47 @@ describe("qa suite planning helpers", () => {
|
||||
).toEqual(["third", "first"]);
|
||||
});
|
||||
|
||||
it("resolves driver channels from scenario execution with explicit and default fallbacks", () => {
|
||||
expect(
|
||||
resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: "telegram",
|
||||
scenarios: [makeQaSuiteTestScenario("plain")],
|
||||
}),
|
||||
).toBe("telegram");
|
||||
expect(
|
||||
resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: "telegram",
|
||||
scenarios: [
|
||||
makeQaSuiteTestScenario("plain"),
|
||||
makeQaSuiteTestScenario("slack-flow", { channel: "slack" }),
|
||||
],
|
||||
}),
|
||||
).toBe("slack");
|
||||
expect(
|
||||
resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: "telegram",
|
||||
explicitChannel: "slack",
|
||||
scenarios: [makeQaSuiteTestScenario("slack-flow", { channel: "slack" })],
|
||||
}),
|
||||
).toBe("slack");
|
||||
expect(() =>
|
||||
resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: "telegram",
|
||||
explicitChannel: "telegram",
|
||||
scenarios: [makeQaSuiteTestScenario("slack-flow", { channel: "slack" })],
|
||||
}),
|
||||
).toThrow("--channel telegram conflicts with selected scenario execution.channel slack.");
|
||||
expect(() =>
|
||||
resolveQaSuiteScenarioChannel({
|
||||
defaultChannel: "telegram",
|
||||
scenarios: [
|
||||
makeQaSuiteTestScenario("slack-flow", { channel: "slack" }),
|
||||
makeQaSuiteTestScenario("telegram-flow", { channel: "telegram" }),
|
||||
],
|
||||
}),
|
||||
).toThrow("Selected QA scenarios require multiple channels");
|
||||
});
|
||||
|
||||
it("collects unique scenario-declared bundled plugins in encounter order", () => {
|
||||
const scenarios = [
|
||||
makeQaSuiteTestScenario("generic", { plugins: ["active-memory", "memory-wiki"] }),
|
||||
|
||||
@@ -108,6 +108,45 @@ function selectQaFlowSuiteScenarios(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function listQaSuiteScenarioChannels(
|
||||
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
scenarios
|
||||
.map((scenario) => scenario.execution.channel?.trim().toLowerCase())
|
||||
.filter((channel): channel is string => Boolean(channel)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveQaSuiteScenarioChannel(params: {
|
||||
defaultChannel: string;
|
||||
explicitChannel?: string | null;
|
||||
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
|
||||
}) {
|
||||
const scenarioChannels = listQaSuiteScenarioChannels(params.scenarios);
|
||||
const explicitChannel = params.explicitChannel?.trim().toLowerCase();
|
||||
if (explicitChannel) {
|
||||
const conflictingChannels = scenarioChannels.filter((channel) => channel !== explicitChannel);
|
||||
if (conflictingChannels.length > 0) {
|
||||
throw new Error(
|
||||
`--channel ${explicitChannel} conflicts with selected scenario execution.channel ${conflictingChannels.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return explicitChannel;
|
||||
}
|
||||
if (scenarioChannels.length === 0) {
|
||||
return params.defaultChannel;
|
||||
}
|
||||
if (scenarioChannels.length === 1) {
|
||||
return scenarioChannels[0];
|
||||
}
|
||||
throw new Error(
|
||||
`Selected QA scenarios require multiple channels (${scenarioChannels.join(", ")}); split the run by channel.`,
|
||||
);
|
||||
}
|
||||
|
||||
function collectQaSuitePluginIds(
|
||||
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
|
||||
) {
|
||||
@@ -279,6 +318,7 @@ export {
|
||||
collectQaSuitePluginIds,
|
||||
mapQaSuiteWithConcurrency,
|
||||
normalizeQaSuiteConcurrency,
|
||||
resolveQaSuiteScenarioChannel,
|
||||
resolveQaSuiteWorkerStartStaggerMs,
|
||||
resolveQaSuiteOutputDir,
|
||||
scenarioRequiresControlUi,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QaSuiteArtifactError } from "./errors.js";
|
||||
import type { QaEvidenceSummaryJson } from "./evidence-summary.js";
|
||||
import type { QaProviderMode } from "./model-selection.js";
|
||||
import type { RuntimeId, RuntimeParityResult } from "./runtime-parity.js";
|
||||
import type { QaScorecardChannelDriver } from "./scorecard-taxonomy.js";
|
||||
|
||||
type QaSuiteSummaryScenario = {
|
||||
name: string;
|
||||
@@ -55,6 +56,10 @@ export type QaSuiteSummaryJson = {
|
||||
alternateModelName: string | null;
|
||||
fastMode: boolean;
|
||||
concurrency: number;
|
||||
channelDriver: QaScorecardChannelDriver | null;
|
||||
channel: string | null;
|
||||
channelCapabilityMatrixPath: string | null;
|
||||
channelDriverSmokePath: string | null;
|
||||
scenarioIds: string[] | null;
|
||||
runtimePair?: [RuntimeId, RuntimeId] | null;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ type QaSuiteTestScenario = ReturnType<typeof readQaBootstrapScenarioCatalog>["sc
|
||||
export function makeQaSuiteTestScenario(
|
||||
id: string,
|
||||
params: {
|
||||
channel?: string;
|
||||
config?: Record<string, unknown>;
|
||||
plugins?: string[];
|
||||
gatewayConfigPatch?: Record<string, unknown>;
|
||||
@@ -27,6 +28,7 @@ export function makeQaSuiteTestScenario(
|
||||
sourcePath: `qa/scenarios/${id}.yaml`,
|
||||
execution: {
|
||||
kind: "flow",
|
||||
...(params.channel ? { channel: params.channel } : {}),
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
flow: { steps: [{ name: "noop", actions: [{ assert: "true" }] }] },
|
||||
},
|
||||
|
||||
@@ -34,9 +34,42 @@ 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("records declarative non-Crabline channel-driver metadata", () => {
|
||||
const json = buildQaSuiteSummaryJson({
|
||||
...baseParams,
|
||||
channelDriver: "live",
|
||||
});
|
||||
|
||||
expect(json.run.channelDriver).toBe("live");
|
||||
expect(json.run.channel).toBeNull();
|
||||
expect(json.run.channelCapabilityMatrixPath).toBeNull();
|
||||
expect(json.run.channelDriverSmokePath).toBeNull();
|
||||
});
|
||||
|
||||
it("includes scenarioIds in run metadata when provided", () => {
|
||||
const scenarioIds = ["approval-turn-tool-followthrough", "subagent-handoff", "memory-recall"];
|
||||
const json = buildQaSuiteSummaryJson({
|
||||
|
||||
@@ -272,6 +272,72 @@ 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-"));
|
||||
const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "telegram-token";
|
||||
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 {
|
||||
report?: { result?: { configured?: Array<{ adapter?: string; platform?: string }> } };
|
||||
};
|
||||
expect(matrix.report?.result?.configured).toEqual([
|
||||
expect.objectContaining({ adapter: "telegram", platform: "telegram" }),
|
||||
]);
|
||||
const smoke = JSON.parse(
|
||||
await fs.readFile(path.join(outputDir, "crabline-channel-smoke.json"), "utf8"),
|
||||
) as { smoke?: { result?: { findings?: string[]; ok?: boolean } } };
|
||||
expect(smoke.smoke?.result).toMatchObject({ findings: [], ok: 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 {
|
||||
if (originalTelegramBotToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = originalTelegramBotToken;
|
||||
}
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("arms gateway heap checkpoint env only when requested", () => {
|
||||
expect(
|
||||
qaSuiteProgressTesting.buildQaGatewayHeapCheckpointRuntimeEnvPatch({
|
||||
|
||||
@@ -12,6 +12,11 @@ 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";
|
||||
@@ -51,7 +56,7 @@ import {
|
||||
type QaSeedScenarioWithSource,
|
||||
} from "./scenario-catalog.js";
|
||||
import { runScenarioFlow } from "./scenario-flow-runner.js";
|
||||
import type { QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
|
||||
import type { QaScorecardChannelDriver, QaScorecardEvidenceMode } from "./scorecard-taxonomy.js";
|
||||
import {
|
||||
applyQaMergePatch,
|
||||
collectQaSuiteGatewayConfigPatch,
|
||||
@@ -107,6 +112,8 @@ export type QaSuiteRunParams = {
|
||||
outputDir?: string;
|
||||
providerMode?: QaProviderMode;
|
||||
transportId?: QaTransportId;
|
||||
channelDriver?: QaScorecardChannelDriver;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
primaryModel?: string;
|
||||
alternateModel?: string;
|
||||
fastMode?: boolean;
|
||||
@@ -418,6 +425,7 @@ function buildRuntimeParityScenarioResult(params: {
|
||||
|
||||
function createQaSuiteReportNotes(params: {
|
||||
transport: QaTransportAdapter;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
providerMode: QaProviderMode;
|
||||
primaryModel: string;
|
||||
alternateModel: string;
|
||||
@@ -425,7 +433,10 @@ function createQaSuiteReportNotes(params: {
|
||||
concurrency: number;
|
||||
isolatedWorkers?: boolean;
|
||||
}) {
|
||||
return params.transport.createReportNotes(params);
|
||||
return [
|
||||
...params.transport.createReportNotes(params),
|
||||
...createQaCrablineChannelReportNotes(params.channelDriverSelection),
|
||||
];
|
||||
}
|
||||
|
||||
function buildQaIsolatedScenarioWorkerParams(params: {
|
||||
@@ -433,6 +444,8 @@ function buildQaIsolatedScenarioWorkerParams(params: {
|
||||
outputDir: string;
|
||||
providerMode: QaProviderMode;
|
||||
transportId: QaTransportId;
|
||||
channelDriver?: QaScorecardChannelDriver;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
primaryModel: string;
|
||||
alternateModel: string;
|
||||
fastMode: boolean;
|
||||
@@ -445,6 +458,8 @@ function buildQaIsolatedScenarioWorkerParams(params: {
|
||||
outputDir: params.outputDir,
|
||||
providerMode: params.providerMode,
|
||||
transportId: params.transportId,
|
||||
channelDriver: params.channelDriver,
|
||||
channelDriverSelection: params.channelDriverSelection,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
fastMode: params.fastMode,
|
||||
@@ -550,6 +565,8 @@ export type QaSuiteSummaryJsonParams = {
|
||||
alternateModel: string;
|
||||
fastMode: boolean;
|
||||
concurrency: number;
|
||||
channelDriver?: QaScorecardChannelDriver | null;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
scenarioIds?: readonly string[];
|
||||
runtimePair?: [RuntimeId, RuntimeId];
|
||||
};
|
||||
@@ -609,6 +626,10 @@ export function buildQaSuiteSummaryJson(params: QaSuiteSummaryJsonParams): QaSui
|
||||
alternateModelName: alternateSplit?.model ?? null,
|
||||
fastMode: params.fastMode,
|
||||
concurrency: params.concurrency,
|
||||
channelDriver: params.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,
|
||||
@@ -629,6 +650,8 @@ async function runQaRuntimeParitySuite(params: {
|
||||
thinkingDefault?: QaThinkingLevel;
|
||||
claudeCliAuthMode?: QaCliBackendAuthMode;
|
||||
enabledPluginIds?: string[];
|
||||
channelDriver?: QaScorecardChannelDriver | null;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
concurrency: number;
|
||||
selectedScenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
|
||||
startLab?: QaSuiteStartLabFn;
|
||||
@@ -701,6 +724,8 @@ async function runQaRuntimeParitySuite(params: {
|
||||
outputDir: cellOutputDir,
|
||||
providerMode: params.providerMode,
|
||||
transportId: params.transportId,
|
||||
channelDriver: params.channelDriver ?? undefined,
|
||||
channelDriverSelection: params.channelDriverSelection,
|
||||
primaryModel: remapModelRefForForcedRuntime({
|
||||
modelRef: params.primaryModel,
|
||||
providerMode: params.providerMode,
|
||||
@@ -802,6 +827,8 @@ async function runQaRuntimeParitySuite(params: {
|
||||
alternateModel: params.alternateModel,
|
||||
fastMode: params.fastMode,
|
||||
concurrency: params.concurrency,
|
||||
channelDriver: params.channelDriver,
|
||||
channelDriverSelection: params.channelDriverSelection,
|
||||
scenarioIds:
|
||||
params.scenarioIds && params.scenarioIds.length > 0
|
||||
? params.selectedScenarios.map((scenario) => scenario.id)
|
||||
@@ -854,6 +881,8 @@ async function writeQaSuiteArtifacts(params: {
|
||||
alternateModel: string;
|
||||
fastMode: boolean;
|
||||
concurrency: number;
|
||||
channelDriver?: QaScorecardChannelDriver | null;
|
||||
channelDriverSelection?: QaCrablineChannelDriverSelection | null;
|
||||
isolatedWorkers?: boolean;
|
||||
scenarioIds?: readonly string[];
|
||||
runtimePair?: [RuntimeId, RuntimeId];
|
||||
@@ -861,6 +890,23 @@ 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, {
|
||||
outputDir: params.outputDir,
|
||||
})
|
||||
: undefined;
|
||||
const channelDriverArtifactPaths = params.channelDriverSelection
|
||||
? [
|
||||
{
|
||||
kind: "channel-capability-matrix",
|
||||
path: params.channelDriverSelection.capabilityMatrixPath,
|
||||
},
|
||||
{
|
||||
kind: "channel-driver-smoke",
|
||||
path: params.channelDriverSelection.smokeArtifactPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const report = renderQaMarkdownReport({
|
||||
title: "OpenClaw QA Scenario Suite",
|
||||
startedAt: params.startedAt,
|
||||
@@ -880,9 +926,11 @@ async function writeQaSuiteArtifacts(params: {
|
||||
artifactPaths: [
|
||||
{ kind: "summary", path: path.basename(summaryPath) },
|
||||
{ kind: "report", path: path.basename(reportPath) },
|
||||
...channelDriverArtifactPaths,
|
||||
],
|
||||
evidenceMode: params.evidenceMode,
|
||||
channelId: params.transport.id,
|
||||
channelId: params.channelDriverSelection?.channel ?? params.transport.id,
|
||||
channelDriver: params.channelDriver ?? params.channelDriverSelection?.channelDriver,
|
||||
env: process.env,
|
||||
generatedAt: params.finishedAt.toISOString(),
|
||||
primaryModel: params.primaryModel,
|
||||
@@ -891,6 +939,40 @@ 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,
|
||||
manifestPath: channelDriverSmoke.manifestPath,
|
||||
report: channelDriverSmoke.capabilityReport,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(params.outputDir, params.channelDriverSelection.smokeArtifactPath),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
source: "openclaw/crabline",
|
||||
channelDriver: params.channelDriverSelection.channelDriver,
|
||||
selectedChannel: params.channelDriverSelection.channel,
|
||||
manifestPath: channelDriverSmoke.manifestPath,
|
||||
smoke: channelDriverSmoke.smoke,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
await fs.writeFile(reportPath, report, "utf8");
|
||||
if (evidence) {
|
||||
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
|
||||
@@ -1117,6 +1199,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
startedAt,
|
||||
providerMode,
|
||||
transportId,
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
channelDriver: params?.channelDriver,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
fastMode,
|
||||
@@ -1190,6 +1274,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
alternateModel,
|
||||
fastMode,
|
||||
concurrency,
|
||||
channelDriver: params?.channelDriver,
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
isolatedWorkers: true,
|
||||
scenarioIds:
|
||||
params?.scenarioIds && params.scenarioIds.length > 0
|
||||
@@ -1238,6 +1324,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
outputDir: scenarioOutputDir,
|
||||
providerMode,
|
||||
transportId,
|
||||
channelDriver: params?.channelDriver,
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
fastMode,
|
||||
@@ -1335,6 +1423,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
alternateModel,
|
||||
fastMode,
|
||||
concurrency,
|
||||
channelDriver: params?.channelDriver,
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
isolatedWorkers: true,
|
||||
// When the caller supplied an explicit non-empty --scenario filter,
|
||||
// record the executed (post-selectQaFlowSuiteScenarios-normalized) ids
|
||||
@@ -1597,6 +1687,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
alternateModel,
|
||||
fastMode,
|
||||
concurrency,
|
||||
channelDriver: params?.channelDriver,
|
||||
channelDriverSelection: params?.channelDriverSelection,
|
||||
isolatedWorkers: false,
|
||||
// Same "filtered → executed list, unfiltered → null" convention as
|
||||
// the concurrent-path writeQaSuiteArtifacts call above.
|
||||
|
||||
@@ -49,6 +49,7 @@ 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";
|
||||
@@ -290,11 +291,13 @@ 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: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
fallbackLimit: telegramTextLimit,
|
||||
}),
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
telegramTextLimit,
|
||||
);
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||
|
||||
@@ -73,6 +73,50 @@ 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: {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
hasBotMention,
|
||||
renderTelegramTextEntities,
|
||||
resolveTelegramPrimaryMedia,
|
||||
resolveTelegramRichMessagePlaceholder,
|
||||
} from "./bot/body-helpers.js";
|
||||
import { buildTelegramGroupPeerId, buildTelegramInboundOriginTarget } from "./bot/helpers.js";
|
||||
import type { TelegramContext } from "./bot/types.js";
|
||||
@@ -239,7 +240,7 @@ export async function resolveTelegramInboundBody(params: {
|
||||
const hasUserText = Boolean(rawText || locationText);
|
||||
let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim();
|
||||
if (!rawBody) {
|
||||
rawBody = placeholder;
|
||||
rawBody = resolveTelegramRichMessagePlaceholder(msg) ?? placeholder;
|
||||
}
|
||||
if (!rawBody && allMedia.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -649,6 +649,61 @@ 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";
|
||||
@@ -1521,7 +1576,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expectDraftStreamParams({ maxChars: 4096 });
|
||||
expectDraftStreamParams({ maxChars: 4000 });
|
||||
});
|
||||
|
||||
it("streams text-only finals into the answer message", async () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ 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 {
|
||||
@@ -116,6 +117,7 @@ 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,
|
||||
@@ -891,20 +893,29 @@ export const dispatchTelegramMessage = async ({
|
||||
const draftMaxChars =
|
||||
streamMode === "block"
|
||||
? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit)
|
||||
: Math.min(textLimit, TELEGRAM_RICH_TEXT_LIMIT);
|
||||
: Math.min(
|
||||
textLimit,
|
||||
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT,
|
||||
);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
supportsBlockTables: true,
|
||||
});
|
||||
const renderStreamText = (text: string) => ({
|
||||
text,
|
||||
richMessage: buildTelegramRichMarkdown(text, {
|
||||
tableMode,
|
||||
skipEntityDetection: telegramCfg.linkPreview === false,
|
||||
}),
|
||||
supportsBlockTables: telegramCfg.richMessages === true,
|
||||
});
|
||||
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";
|
||||
@@ -988,6 +999,7 @@ export const dispatchTelegramMessage = async ({
|
||||
maxChars: draftMaxChars,
|
||||
thread: threadSpec,
|
||||
replyToMessageId: draftReplyToMessageId,
|
||||
richMessages: telegramCfg.richMessages,
|
||||
minInitialChars: draftMinInitialChars,
|
||||
renderText: renderStreamText,
|
||||
onSupersededPreview: (superseded) => {
|
||||
@@ -1507,6 +1519,7 @@ export const dispatchTelegramMessage = async ({
|
||||
thread: threadSpec,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
richMessages: telegramCfg.richMessages,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
replyQuoteMessageId,
|
||||
replyQuoteText,
|
||||
|
||||
@@ -703,6 +703,25 @@ 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();
|
||||
|
||||
|
||||
@@ -973,6 +973,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||
chunkMode: TelegramChunkMode;
|
||||
linkPreview?: boolean;
|
||||
richMessages?: boolean;
|
||||
}) => ({
|
||||
cfg: params.cfg,
|
||||
chatId: String(params.chatId),
|
||||
@@ -992,6 +993,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode: params.tableMode,
|
||||
chunkMode: params.chunkMode,
|
||||
linkPreview: params.linkPreview,
|
||||
richMessages: params.richMessages,
|
||||
});
|
||||
const resolveCommandTargetSessionKey = (params: {
|
||||
runtimeCfg: OpenClawConfig;
|
||||
@@ -1209,6 +1211,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
richMessages: runtimeTelegramCfg.richMessages,
|
||||
});
|
||||
let topicName: string | undefined;
|
||||
if (isForum && resolvedThreadId != null) {
|
||||
@@ -1431,6 +1434,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: runtimeTelegramCfg.linkPreview,
|
||||
richMessages: runtimeTelegramCfg.richMessages,
|
||||
});
|
||||
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
|
||||
const to = `telegram:${chatId}`;
|
||||
|
||||
@@ -93,6 +93,22 @@ 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);
|
||||
@@ -108,9 +124,7 @@ export function resolveTelegramTextContent(text: unknown, caption?: unknown): st
|
||||
return isBinaryContent(raw) ? "" : raw;
|
||||
}
|
||||
|
||||
export function getTelegramTextParts(
|
||||
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
|
||||
): {
|
||||
export function getTelegramTextParts(msg: TelegramTextMessage): {
|
||||
text: string;
|
||||
entities: TelegramTextEntity[];
|
||||
} {
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
createOutboundPayloadPlan,
|
||||
projectOutboundPayloadPlanForDelivery,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
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 type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
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 type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { chunkMarkdownTextWithMode, 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,16 +32,13 @@ 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,
|
||||
splitTelegramHtmlChunks,
|
||||
telegramHtmlToPlainTextFallback,
|
||||
wrapFileReferencesInHtml,
|
||||
} from "../format.js";
|
||||
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
|
||||
import {
|
||||
splitTelegramRichMessageTextChunks,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
type TelegramRichTextChunk,
|
||||
} from "../rich-message.js";
|
||||
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
|
||||
import { buildInlineKeyboard } from "../send.js";
|
||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||
import {
|
||||
@@ -60,7 +57,6 @@ 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;
|
||||
|
||||
@@ -80,54 +76,61 @@ type TelegramReplyQuoteForSend = {
|
||||
entities?: unknown[];
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[];
|
||||
type TelegramDeliveryTextChunk = {
|
||||
text: string;
|
||||
plainText: string;
|
||||
textMode: "html";
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[];
|
||||
|
||||
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) => {
|
||||
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),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
return splitTelegramRichMessageTextChunks({
|
||||
text: markdown,
|
||||
textLimit: params.textLimit,
|
||||
textMode: "markdown",
|
||||
chunkMode: params.chunkMode,
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
});
|
||||
return chunks.map((chunk) => ({
|
||||
text: chunk.html,
|
||||
plainText: chunk.text,
|
||||
textMode: "html" as const,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -191,10 +194,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;
|
||||
@@ -223,11 +226,12 @@ 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) {
|
||||
@@ -246,10 +250,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;
|
||||
@@ -267,11 +271,12 @@ 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -312,12 +317,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));
|
||||
@@ -334,11 +339,12 @@ 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;
|
||||
@@ -358,6 +364,7 @@ async function deliverMediaReply(params: {
|
||||
runtime: RuntimeEnv;
|
||||
thread?: TelegramThreadSpec | null;
|
||||
tableMode?: MarkdownTableMode;
|
||||
richMessages?: boolean;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaMaxBytes?: number;
|
||||
chunkText: ChunkTextFn;
|
||||
@@ -373,7 +380,6 @@ 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;
|
||||
@@ -520,11 +526,12 @@ 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;
|
||||
@@ -552,10 +559,11 @@ 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;
|
||||
}
|
||||
@@ -602,10 +610,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,
|
||||
@@ -736,6 +744,8 @@ 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). */
|
||||
@@ -765,19 +775,17 @@ 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: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
|
||||
textLimit:
|
||||
params.richMessages === true
|
||||
? Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT)
|
||||
: Math.min(params.textLimit, 4000),
|
||||
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) {
|
||||
@@ -881,9 +889,6 @@ export async function deliverReplies(params: {
|
||||
presentation,
|
||||
interactive,
|
||||
}),
|
||||
{
|
||||
chatType: replyChatType,
|
||||
},
|
||||
);
|
||||
let firstDeliveredMessageId: number | undefined;
|
||||
if (mediaList.length === 0) {
|
||||
@@ -899,10 +904,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,
|
||||
@@ -916,6 +921,7 @@ export async function deliverReplies(params: {
|
||||
runtime: params.runtime,
|
||||
thread: params.thread,
|
||||
tableMode: params.tableMode,
|
||||
richMessages: params.richMessages,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaMaxBytes: params.mediaMaxBytes,
|
||||
chunkText,
|
||||
@@ -923,7 +929,6 @@ 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,
|
||||
|
||||
@@ -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 { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "../format.js";
|
||||
import { markdownToTelegramHtml } from "../format.js";
|
||||
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
|
||||
import {
|
||||
buildTelegramSendParams,
|
||||
@@ -23,8 +23,9 @@ import type { TelegramThreadSpec } from "./helpers.js";
|
||||
|
||||
export { buildTelegramSendParams } from "../reply-parameters.js";
|
||||
|
||||
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 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 GrammyErrorCtor: typeof GrammyError | undefined =
|
||||
typeof GrammyError === "function" ? GrammyError : undefined;
|
||||
|
||||
@@ -35,13 +36,6 @@ 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),
|
||||
@@ -82,14 +76,14 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
||||
} catch (err) {
|
||||
if (hasNativeQuote && isTelegramQuoteParamError(err)) {
|
||||
params.runtime.log?.(
|
||||
`telegram ${params.operation}: native quote rejected; retrying without quote text`,
|
||||
`telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`,
|
||||
);
|
||||
const removeNativeQuoteParam =
|
||||
params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam;
|
||||
return await sendTelegramWithThreadFallback({
|
||||
...params,
|
||||
operation: `${params.operation} (reply retry)`,
|
||||
requestParams: removeNativeQuoteParam(params.requestParams),
|
||||
operation: `${params.operation} (legacy reply retry)`,
|
||||
requestParams: (params.removeNativeQuoteParam ?? removeTelegramNativeQuoteParam)(
|
||||
params.requestParams,
|
||||
),
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
@@ -109,11 +103,12 @@ 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({
|
||||
@@ -125,86 +120,88 @@ export async function sendTelegramText(
|
||||
thread: opts?.thread,
|
||||
silent: opts?.silent,
|
||||
});
|
||||
const richParams = toTelegramRichMessageContextParams(baseParams);
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const normalizedChatId = chatId.trim();
|
||||
const shouldUseRichText =
|
||||
opts?.chatType !== "group" &&
|
||||
(opts?.thread?.scope === "dm" || (!opts?.thread && !normalizedChatId.startsWith("-")));
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Telegram sends");
|
||||
}
|
||||
if (!shouldUseRichText) {
|
||||
const htmlText = renderTelegramHtmlText(text, {
|
||||
textMode,
|
||||
tableMode: opts?.tableMode,
|
||||
if (opts?.richMessages === true) {
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode: opts.tableMode,
|
||||
});
|
||||
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 sendMessage failed with HTML parse error; retrying as plain text: ${formatErrorMessage(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
res = await sendLegacy(
|
||||
"sendMessage (plain)",
|
||||
telegramHtmlToPlainTextFallback(htmlText),
|
||||
plainParams,
|
||||
);
|
||||
}
|
||||
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);
|
||||
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 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;
|
||||
// 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();
|
||||
}
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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) {
|
||||
throw err;
|
||||
}
|
||||
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
|
||||
return await sendPlainFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +166,6 @@ 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({
|
||||
@@ -817,7 +813,7 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips rich entity detection when link previews are disabled", async () => {
|
||||
it("disables link previews without rich-only entity flags", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 3,
|
||||
@@ -834,99 +830,10 @@ describe("deliverReplies", () => {
|
||||
|
||||
expect(firstMockCallArg(sendMessage, 0)).toBe("123");
|
||||
firstSendText(sendMessage);
|
||||
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" },
|
||||
expectRecordFields(mockCallArg(sendMessage, 0, 2), {
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
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);
|
||||
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("skip_entity_detection");
|
||||
});
|
||||
|
||||
it("includes message_thread_id for DM topics", async () => {
|
||||
@@ -1193,6 +1100,48 @@ 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({
|
||||
|
||||
@@ -507,6 +507,24 @@ 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,
|
||||
@@ -710,6 +728,23 @@ 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({
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
renderTelegramTextEntities,
|
||||
resolveTelegramTextContent,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
resolveTelegramRichMessagePlaceholder,
|
||||
type TelegramForwardedContext,
|
||||
type TelegramTextEntity,
|
||||
} from "./body-helpers.js";
|
||||
@@ -56,6 +57,7 @@ export {
|
||||
normalizeForwardedContext,
|
||||
renderTelegramTextEntities,
|
||||
resolveTelegramMediaPlaceholder,
|
||||
resolveTelegramRichMessagePlaceholder,
|
||||
};
|
||||
|
||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||
@@ -619,11 +621,12 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
||||
: replyLike && typeof replyLike.caption === "string"
|
||||
? replyLike.caption
|
||||
: undefined;
|
||||
const safeReplyText = resolveTelegramTextContent(rawReplyText);
|
||||
const replyTextParts = replyLike && safeReplyText ? getTelegramTextParts(replyLike) : undefined;
|
||||
const replyTextParts = replyLike ? getTelegramTextParts(replyLike) : undefined;
|
||||
const safeReplyText = replyTextParts?.text ?? "";
|
||||
let filteredReplyText = false;
|
||||
if (!body && replyLike) {
|
||||
const replyBody = safeReplyText.trim();
|
||||
const replyBody =
|
||||
safeReplyText.trim() || resolveTelegramRichMessagePlaceholder(replyLike) || "";
|
||||
filteredReplyText = hadUnsafeTelegramText(rawReplyText, replyBody);
|
||||
body = replyBody;
|
||||
if (!body) {
|
||||
|
||||
@@ -23,12 +23,78 @@ describe("telegram actions contract", () => {
|
||||
],
|
||||
});
|
||||
|
||||
it("advertises Telegram rich text to the agent prompt", () => {
|
||||
it.each([
|
||||
{ richMessages: undefined, expected: false },
|
||||
{ richMessages: false, expected: false },
|
||||
{ richMessages: true, expected: true },
|
||||
])("advertises Telegram rich text only when enabled", ({ richMessages, expected }) => {
|
||||
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,
|
||||
|
||||
@@ -36,7 +36,12 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js";
|
||||
import {
|
||||
mergeTelegramAccountConfig,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
type ResolvedTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
import { resolveTelegramAutoThreadId } from "./action-threading.js";
|
||||
import { lookupTelegramChatId } from "./api-fetch.js";
|
||||
import { telegramApprovalCapability } from "./approval-native.js";
|
||||
@@ -783,7 +788,12 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return inlineButtonsScope === "off" ? ["richText"] : ["inlineButtons", "richText"];
|
||||
const capabilities = inlineButtonsScope === "off" ? [] : ["inlineButtons"];
|
||||
const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg);
|
||||
if (mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true) {
|
||||
capabilities.push("richText");
|
||||
}
|
||||
return capabilities;
|
||||
},
|
||||
reactionGuidance: ({ cfg, accountId }) => {
|
||||
const level = resolveTelegramReactionLevel({
|
||||
|
||||
@@ -153,6 +153,19 @@ 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 " }],
|
||||
|
||||
@@ -62,6 +62,10 @@ 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".',
|
||||
|
||||
@@ -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 { markdownToTelegramRichHtml } from "./format.js";
|
||||
import type { TelegramInputRichMessage } from "./rich-message.js";
|
||||
|
||||
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
|
||||
|
||||
@@ -47,50 +47,56 @@ async function expectInitialForumSend(
|
||||
text = "Hello",
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() =>
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, text, {
|
||||
message_thread_id: 99,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectRichSend(
|
||||
function expectPreviewSend(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
text: string,
|
||||
params: Record<string, unknown> = {},
|
||||
) {
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
...params,
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, text, params);
|
||||
}
|
||||
|
||||
function expectNthRichSend(
|
||||
function expectNthPreviewSend(
|
||||
api: ReturnType<typeof createMockDraftApi>,
|
||||
call: number,
|
||||
text: string,
|
||||
params: Record<string, unknown> = {},
|
||||
) {
|
||||
expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, {
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text) },
|
||||
...params,
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenNthCalledWith(call, 123, text, params);
|
||||
}
|
||||
|
||||
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 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 createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const stream = createDraftStream(
|
||||
@@ -115,12 +121,12 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
stream.update("Hello");
|
||||
await expectInitialForumSend(api);
|
||||
await (api.raw.sendRichMessage.mock.results[0]?.value as Promise<unknown>);
|
||||
await (api.sendMessage.mock.results[0]?.value as Promise<unknown>);
|
||||
|
||||
stream.update("Hello again");
|
||||
await stream.flush();
|
||||
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
});
|
||||
|
||||
it("waits for in-flight updates before final flush edit", async () => {
|
||||
@@ -132,15 +138,15 @@ describe("createTelegramDraftStream", () => {
|
||||
const stream = createForumDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
stream.update("Hello final");
|
||||
const flushPromise = stream.flush();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
resolveSend?.({ message_id: 17 });
|
||||
await flushPromise;
|
||||
|
||||
expectRichEdit(api, "Hello final");
|
||||
expectPreviewEdit(api, "Hello final");
|
||||
});
|
||||
|
||||
it("omits message_thread_id for general topic id", async () => {
|
||||
@@ -149,21 +155,21 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
stream.update("Hello");
|
||||
|
||||
await vi.waitFor(() => expectRichSend(api, "Hello"));
|
||||
await vi.waitFor(() => expectPreviewSend(api, "Hello"));
|
||||
});
|
||||
|
||||
it("uses rich send/edit for dm thread previews", async () => {
|
||||
it("uses text send/edit for dm thread previews", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expectRichSend(api, "Hello", { message_thread_id: 42 }));
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expectPreviewSend(api, "Hello", { message_thread_id: 42 }));
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
stream.update("Hello again");
|
||||
await stream.flush();
|
||||
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
});
|
||||
|
||||
it("tracks when a message preview first became visible", async () => {
|
||||
@@ -192,7 +198,7 @@ describe("createTelegramDraftStream", () => {
|
||||
"does not retry %s message preview sends without the topic id",
|
||||
async (scope) => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
api.sendMessage.mockRejectedValueOnce(
|
||||
new Error("400: Bad Request: message thread not found"),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -204,8 +210,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichSend(api, "Hello", { message_thread_id: 42 });
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"telegram stream preview failed: 400: Bad Request: message thread not found",
|
||||
);
|
||||
@@ -217,7 +223,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("does not finalize stale preview text after a stopped send failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(new Error("temporary send failure"));
|
||||
api.sendMessage.mockRejectedValueOnce(new Error("temporary send failure"));
|
||||
const warn = vi.fn();
|
||||
const stream = createDraftStream(api, { warn });
|
||||
|
||||
@@ -225,8 +231,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichSend(api, "Hello");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewSend(api, "Hello");
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: temporary send failure");
|
||||
});
|
||||
|
||||
@@ -240,7 +246,7 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Hello", {
|
||||
expectPreviewSend(api, "Hello", {
|
||||
message_thread_id: 42,
|
||||
reply_parameters: {
|
||||
message_id: 411,
|
||||
@@ -249,13 +255,13 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes message previews using rendered rich HTML", async () => {
|
||||
it("materializes message previews using rendered HTML text", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, {
|
||||
thread: { id: 42, scope: "dm" },
|
||||
renderText: (text) => ({
|
||||
text: text.replace("**bold**", "<b>bold</b>"),
|
||||
richMessage: { html: text.replace("**bold**", "<b>bold</b>") },
|
||||
parseMode: "HTML",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -264,12 +270,11 @@ describe("createTelegramDraftStream", () => {
|
||||
const materializedId = await stream.materialize?.();
|
||||
|
||||
expect(materializedId).toBe(17);
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: "<b>bold</b>" },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
|
||||
parse_mode: "HTML",
|
||||
message_thread_id: 42,
|
||||
});
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns existing preview id when materializing message transport", async () => {
|
||||
@@ -283,7 +288,8 @@ describe("createTelegramDraftStream", () => {
|
||||
const materializedId = await stream.materialize?.();
|
||||
|
||||
expect(materializedId).toBe(17);
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes message preview on clear after finalization", async () => {
|
||||
@@ -296,8 +302,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.stop();
|
||||
await stream.clear();
|
||||
|
||||
expectRichSend(api, "Hello", { message_thread_id: 42 });
|
||||
expectRichEdit(api, "Hello again");
|
||||
expectPreviewSend(api, "Hello", { message_thread_id: 42 });
|
||||
expectPreviewEdit(api, "Hello again");
|
||||
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
|
||||
});
|
||||
|
||||
@@ -307,12 +313,12 @@ describe("createTelegramDraftStream", () => {
|
||||
// First message
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Normal edit (same message)
|
||||
stream.update("Hello edited");
|
||||
await stream.flush();
|
||||
expectRichEdit(api, "Hello edited");
|
||||
expectPreviewEdit(api, "Hello edited");
|
||||
|
||||
// Force new message (e.g. after thinking block ends)
|
||||
stream.forceNewMessage();
|
||||
@@ -320,8 +326,8 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
|
||||
// Should have sent a second new message, not edited the first
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 2, "After thinking");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "After thinking");
|
||||
});
|
||||
|
||||
it("creates new message after cleanup and forceNewMessage", async () => {
|
||||
@@ -337,8 +343,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Next preview");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 2, "Next preview");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "Next preview");
|
||||
});
|
||||
|
||||
it("sends first update immediately after forceNewMessage within throttle window", async () => {
|
||||
@@ -347,15 +353,15 @@ describe("createTelegramDraftStream", () => {
|
||||
const { api, stream } = createForceNewMessageHarness({ throttleMs: 1000 });
|
||||
|
||||
stream.update("Hello");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
stream.update("Hello edited");
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
|
||||
stream.forceNewMessage();
|
||||
stream.update("Second message");
|
||||
await vi.waitFor(() => expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2));
|
||||
expectNthRichSend(api, 2, "Second message");
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(2));
|
||||
expectNthPreviewSend(api, 2, "Second message");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -367,14 +373,12 @@ describe("createTelegramDraftStream", () => {
|
||||
resolveFirstSend = resolve;
|
||||
});
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
.mockReturnValueOnce(firstSend)
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
api.sendMessage.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.raw.sendRichMessage).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
stream.forceNewMessage();
|
||||
stream.update("Message B partial");
|
||||
@@ -392,44 +396,38 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
expect(typeof supersededPreview.visibleSinceMs).toBe("number");
|
||||
expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true);
|
||||
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") },
|
||||
});
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 2, "Message B partial");
|
||||
expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial");
|
||||
});
|
||||
|
||||
it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
new Error("timeout after Telegram accepted send"),
|
||||
);
|
||||
api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send"));
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(true);
|
||||
});
|
||||
|
||||
async function expectSendMayHaveLandedStateAfterFirstFailure(error: Error, expected: boolean) {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(error);
|
||||
api.sendMessage.mockRejectedValueOnce(error);
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
stream.update("Hello");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(expected);
|
||||
}
|
||||
|
||||
it("retries pre-connect first preview send failures instead of stopping", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage.mockRejectedValueOnce(
|
||||
api.sendMessage.mockRejectedValueOnce(
|
||||
Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
);
|
||||
const stream = createDraftStream(api);
|
||||
@@ -438,7 +436,7 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(stream.sendMayHaveLanded?.()).toBe(false);
|
||||
expect(stream.messageId()).toBe(17);
|
||||
});
|
||||
@@ -452,7 +450,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("treats message-is-not-modified edits as delivered", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error("Call to 'editMessageText' failed! (400: Bad Request: message is not modified)"),
|
||||
{ error_code: 400 },
|
||||
@@ -468,18 +466,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello more");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries the preview edit after a transient network failure", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -495,12 +489,8 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello again") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello again");
|
||||
expect(stream.lastDeliveredText?.()).toBe("Hello again");
|
||||
});
|
||||
|
||||
@@ -508,7 +498,7 @@ describe("createTelegramDraftStream", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValueOnce(
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 1)"),
|
||||
{ error_code: 429, parameters: { retry_after: 1 } },
|
||||
@@ -522,17 +512,13 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
stream.update("Hello more");
|
||||
await stream.flush();
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(1);
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
|
||||
});
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
expect(api.editMessageText).toHaveBeenLastCalledWith(123, 17, "Hello more");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -540,7 +526,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("stops the preview after repeated retryable edit failures", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.editMessageText.mockRejectedValue(
|
||||
api.editMessageText.mockRejectedValue(
|
||||
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }),
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -555,35 +541,32 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledTimes(4);
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(4);
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview failed: read ECONNRESET");
|
||||
});
|
||||
|
||||
it("supports rendered previews with rich HTML", async () => {
|
||||
it("supports rendered previews with HTML parse mode", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
chatId: 123,
|
||||
renderText: (text) => ({ text: `<i>${text}</i>`, richMessage: { html: `<i>${text}</i>` } }),
|
||||
renderText: (text) => ({ text: `<i>${text}</i>`, parseMode: "HTML" }),
|
||||
});
|
||||
|
||||
stream.update("hello");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: "<i>hello</i>" },
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<i>hello</i>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
|
||||
stream.update("hello again");
|
||||
await stream.flush();
|
||||
expect(api.raw.editMessageText).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
message_id: 17,
|
||||
rich_message: { html: "<i>hello again</i>" },
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "<i>hello again</i>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses caller-provided rich previews", async () => {
|
||||
it("sends caller-provided rich previews through standard text transport", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api);
|
||||
|
||||
@@ -596,13 +579,10 @@ describe("createTelegramDraftStream", () => {
|
||||
});
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: {
|
||||
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>Shelling</b><br><b>🛠️ Exec</b>", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
|
||||
stream.updatePreview({
|
||||
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
|
||||
@@ -613,43 +593,76 @@ 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: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
rich_message: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
|
||||
});
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
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();
|
||||
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,
|
||||
richMessage: { html: markdownToTelegramRichHtml(value) },
|
||||
}),
|
||||
renderText: (value) => ({ text: value }),
|
||||
});
|
||||
|
||||
stream.update(text);
|
||||
await stream.flush();
|
||||
|
||||
expect(richApi.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) },
|
||||
});
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
const sentText = requireSendMessageCallText(api, 0);
|
||||
expect(sentText.length).toBeLessThanOrEqual(4000);
|
||||
expect(sentText.startsWith("# Long\n\nrich line")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-final overflow in one editable preview", async () => {
|
||||
@@ -662,9 +675,9 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthRichSend(api, 1, "Hello world");
|
||||
expectRichEdit(api, "Hello world foo bar");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(api, 1, "Hello world");
|
||||
expectPreviewEdit(api, "Hello world foo bar");
|
||||
expect(onSupersededPreview).not.toHaveBeenCalled();
|
||||
expect(stream.lastDeliveredText?.()).toBe("Hello world foo bar");
|
||||
});
|
||||
@@ -682,14 +695,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectRichEdit(api, "Hello world foo bar");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectPreviewEdit(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.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const stream = createDraftStream(api, { maxChars: 20 });
|
||||
@@ -699,9 +712,9 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("Hello world foo bar baz qux");
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 1, "Hello world");
|
||||
expectNthRichSend(api, 2, "foo bar baz qux");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 1, "Hello world");
|
||||
expectNthPreviewSend(api, 2, "foo bar baz qux");
|
||||
});
|
||||
|
||||
it("clamps a first oversized non-final preview", async () => {
|
||||
@@ -711,14 +724,14 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("1234567890ABCDEFGHIJ");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(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.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const onSupersededPreview = vi.fn();
|
||||
@@ -731,9 +744,9 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expectNthRichSend(api, 2, "ABCDEFGHIJ");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJ");
|
||||
expect(onSupersededPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -745,7 +758,7 @@ describe("createTelegramDraftStream", () => {
|
||||
|
||||
it("continues finalizing more than two overflow chunks after a clamped preview", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 })
|
||||
.mockResolvedValueOnce({ message_id: 43 });
|
||||
@@ -755,16 +768,16 @@ describe("createTelegramDraftStream", () => {
|
||||
await stream.flush();
|
||||
await stream.stop();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(3);
|
||||
expectNthRichSend(api, 1, "1234567890");
|
||||
expectNthRichSend(api, 2, "ABCDEFGHIJ");
|
||||
expectNthRichSend(api, 3, "KLMNOPQRST");
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(3);
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expectNthPreviewSend(api, 2, "ABCDEFGHIJ");
|
||||
expectNthPreviewSend(api, 3, "KLMNOPQRST");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890ABCDEFGHIJKLMNOPQRST");
|
||||
});
|
||||
|
||||
it("retains final overflow preview pages", async () => {
|
||||
const api = createMockDraftApi();
|
||||
api.raw.sendRichMessage
|
||||
api.sendMessage
|
||||
.mockResolvedValueOnce({ message_id: 17 })
|
||||
.mockResolvedValueOnce({ message_id: 42 });
|
||||
const onSupersededPreview = vi.fn();
|
||||
@@ -798,8 +811,8 @@ describe("createTelegramDraftStream", () => {
|
||||
chatId: 123,
|
||||
maxChars: 100,
|
||||
renderText: () => ({
|
||||
text: "short raw text",
|
||||
richMessage: { html: `<b>${"<".repeat(120)}</b>` },
|
||||
text: `<b>${"<".repeat(120)}</b>`,
|
||||
parseMode: "HTML",
|
||||
}),
|
||||
warn,
|
||||
});
|
||||
@@ -807,8 +820,8 @@ describe("createTelegramDraftStream", () => {
|
||||
stream.update("short raw text");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith("telegram stream preview stopped (text length 127 > 100)");
|
||||
});
|
||||
});
|
||||
@@ -841,7 +854,7 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.stop();
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Y");
|
||||
expectPreviewSend(api, "Y");
|
||||
});
|
||||
|
||||
it("sends immediately on stop() with short sentence", async () => {
|
||||
@@ -852,7 +865,7 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.stop();
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Ok.");
|
||||
expectPreviewSend(api, "Ok.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -864,7 +877,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("Processing");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send a first message when discard() supersedes a short partial", async () => {
|
||||
@@ -875,8 +888,8 @@ describe("draft stream initial message debounce", () => {
|
||||
await stream.discard?.();
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
expect(api.raw.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends first message when reaching threshold", async () => {
|
||||
@@ -886,7 +899,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("I am processing your request..");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works with longer text above threshold", async () => {
|
||||
@@ -896,7 +909,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("I am processing your request, please wait a moment");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -907,18 +920,18 @@ describe("draft stream initial message debounce", () => {
|
||||
|
||||
stream.update("I am processing your request..");
|
||||
await stream.flush();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
stream.update("I am processing your request.. and summarizing");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.editMessageText).toHaveBeenCalled();
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.editMessageText).toHaveBeenCalled();
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default behavior without debounce params", () => {
|
||||
it("sends rich markdown immediately without minInitialChars set", async () => {
|
||||
it("sends plain preview text immediately without minInitialChars set", async () => {
|
||||
const api = createMockApi();
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
@@ -928,7 +941,7 @@ describe("draft stream initial message debounce", () => {
|
||||
stream.update("Hi");
|
||||
await stream.flush();
|
||||
|
||||
expectRichSend(api, "Hi");
|
||||
expectPreviewSend(api, "Hi");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -14,17 +15,20 @@ import {
|
||||
isTelegramRateLimitError,
|
||||
readTelegramRetryAfterMs,
|
||||
} from "./network-errors.js";
|
||||
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
|
||||
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
|
||||
import {
|
||||
buildTelegramRichMarkdown,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
getTelegramRichRawApi,
|
||||
isTelegramRichMessageWithinStructuralLimits,
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
type TelegramInputRichMessage,
|
||||
type TelegramSendRichMessageParams,
|
||||
} from "./rich-message.js";
|
||||
|
||||
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_RICH_TEXT_LIMIT;
|
||||
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_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.
|
||||
@@ -55,7 +59,8 @@ export type TelegramDraftStream = {
|
||||
|
||||
export type TelegramDraftPreview = {
|
||||
text: string;
|
||||
richMessage: TelegramInputRichMessage;
|
||||
parseMode?: "HTML";
|
||||
richMessage?: TelegramInputRichMessage;
|
||||
};
|
||||
|
||||
type SupersededTelegramPreview = {
|
||||
@@ -65,29 +70,76 @@ 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, richMessage: buildTelegramRichMarkdown(trimmed) }
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function telegramDraftPreviewKey(preview: TelegramDraftPreview): string {
|
||||
return JSON.stringify(preview.richMessage);
|
||||
return JSON.stringify({
|
||||
text: preview.text,
|
||||
parseMode: preview.parseMode ?? "plain",
|
||||
richMessage: preview.richMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number {
|
||||
const richMessage = preview.richMessage;
|
||||
return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length;
|
||||
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 findTelegramDraftChunkLength(
|
||||
text: string,
|
||||
maxChars: number,
|
||||
renderText: ((text: string) => TelegramDraftPreview) | undefined,
|
||||
richMessages: boolean,
|
||||
): number {
|
||||
let best = 0;
|
||||
let low = 1;
|
||||
@@ -95,7 +147,11 @@ function findTelegramDraftChunkLength(
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText);
|
||||
if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) {
|
||||
const renderedText = normalizeTelegramDraftTransportPreview(preview).text.trimEnd();
|
||||
const payloadLength = richMessages
|
||||
? telegramDraftRichPayloadLength(preview)
|
||||
: renderedText.length;
|
||||
if (renderedText && payloadLength <= maxChars) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
@@ -111,6 +167,7 @@ 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;
|
||||
@@ -121,16 +178,25 @@ export function createTelegramDraftStream(params: {
|
||||
log?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
}): TelegramDraftStream {
|
||||
const maxChars = Math.min(
|
||||
params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS,
|
||||
TELEGRAM_STREAM_MAX_CHARS,
|
||||
);
|
||||
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 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 richReplyParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
|
||||
const sendMessageParams =
|
||||
replyToMessageId != null
|
||||
? {
|
||||
...threadParams,
|
||||
reply_parameters: {
|
||||
message_id: replyToMessageId,
|
||||
allow_sending_without_reply: true,
|
||||
},
|
||||
}
|
||||
: (threadParams ?? {});
|
||||
const richMessageParams: Omit<TelegramSendRichMessageParams, "chat_id" | "rich_message"> =
|
||||
replyToMessageId != null
|
||||
? {
|
||||
...threadParams,
|
||||
@@ -159,25 +225,60 @@ export function createTelegramDraftStream(params: {
|
||||
sendGeneration: number;
|
||||
};
|
||||
const sendRenderedMessage = async (preview: TelegramDraftPreview) => {
|
||||
const richRawApi = getTelegramRichRawApi(params.api);
|
||||
return await richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: preview.richMessage,
|
||||
...richReplyParams,
|
||||
});
|
||||
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 sendMessageTransportPreview = async ({
|
||||
preview,
|
||||
sendGeneration,
|
||||
}: PreviewSendParams): Promise<boolean> => {
|
||||
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
|
||||
if (typeof streamMessageId === "number") {
|
||||
streamVisibleSinceMs ??= Date.now();
|
||||
const richRawApi = getTelegramRichRawApi(params.api);
|
||||
await richRawApi.editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: streamMessageId,
|
||||
rich_message: preview.richMessage,
|
||||
});
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
messageSendAttempted = true;
|
||||
@@ -239,15 +340,23 @@ export function createTelegramDraftStream(params: {
|
||||
deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed
|
||||
? lastRequestedPreview
|
||||
: renderTelegramDraftPreview(currentText, params.renderText);
|
||||
const renderedText = rendered.text.trimEnd();
|
||||
const transportPreview = normalizeTelegramDraftTransportPreview(rendered);
|
||||
const renderedText = transportPreview.text.trimEnd();
|
||||
const renderedPayloadLength = richMessages
|
||||
? telegramDraftRichPayloadLength(rendered)
|
||||
: renderedText.length;
|
||||
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);
|
||||
const chunkLength = findTelegramDraftChunkLength(
|
||||
currentText,
|
||||
maxChars,
|
||||
params.renderText,
|
||||
richMessages,
|
||||
);
|
||||
if (!streamState.final) {
|
||||
if (chunkLength > 0) {
|
||||
return await sendOrEditStreamMessage(
|
||||
|
||||
@@ -226,6 +226,35 @@ 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",
|
||||
@@ -689,6 +718,49 @@ 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()
|
||||
@@ -925,6 +997,8 @@ type TelegramHtmlTag = {
|
||||
name: string;
|
||||
openTag: string;
|
||||
closeTag: string;
|
||||
richBlock: boolean;
|
||||
richMedia: boolean;
|
||||
};
|
||||
|
||||
const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS;
|
||||
@@ -945,6 +1019,13 @@ 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;
|
||||
@@ -1018,22 +1099,34 @@ function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
export function splitTelegramHtmlChunks(
|
||||
html: string,
|
||||
limit: number,
|
||||
options: { blockLimit?: number; mediaLimit?: number } = {},
|
||||
): string[] {
|
||||
if (!html) {
|
||||
return [];
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.floor(limit));
|
||||
if (html.length <= normalizedLimit) {
|
||||
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) {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1096,16 +1189,24 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
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 &&
|
||||
current.length +
|
||||
rawTag.length +
|
||||
buildTelegramHtmlCloseSuffixLength(openTags) +
|
||||
nextCloseLength >
|
||||
normalizedLimit
|
||||
((blockLimit !== undefined && isRichBlock && currentBlockCount >= blockLimit) ||
|
||||
(mediaLimit !== undefined && isRichMedia && currentMediaCount >= mediaLimit) ||
|
||||
current.length +
|
||||
rawTag.length +
|
||||
buildTelegramHtmlCloseSuffixLength(openTags) +
|
||||
nextCloseLength >
|
||||
normalizedLimit)
|
||||
) {
|
||||
flushCurrent();
|
||||
}
|
||||
@@ -1115,6 +1216,12 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
if (isSelfClosing) {
|
||||
chunkHasPayload = true;
|
||||
}
|
||||
if (isRichBlock) {
|
||||
currentBlockCount += 1;
|
||||
}
|
||||
if (isRichMedia) {
|
||||
currentMediaCount += 1;
|
||||
}
|
||||
if (isClosing) {
|
||||
popTelegramHtmlTag(openTags, tagName);
|
||||
} else if (!isSelfClosing) {
|
||||
@@ -1122,6 +1229,8 @@ export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
|
||||
name: tagName,
|
||||
openTag: rawTag,
|
||||
closeTag: `</${tagName}>`,
|
||||
richBlock: isRichBlock,
|
||||
richMedia: isRichMedia,
|
||||
});
|
||||
}
|
||||
lastIndex = tagEnd;
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
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;
|
||||
@@ -22,11 +19,6 @@ 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 } };
|
||||
@@ -36,16 +28,14 @@ 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((button) => toInlineKeyboardButton(button, chatType))
|
||||
.map(toInlineKeyboardButton)
|
||||
.filter((button): button is InlineKeyboardButton => Boolean(button)),
|
||||
)
|
||||
.filter((row) => row.length > 0);
|
||||
|
||||
@@ -557,6 +557,49 @@ 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]) {
|
||||
|
||||
@@ -7,7 +7,10 @@ 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 } from "./bot/body-helpers.js";
|
||||
import {
|
||||
resolveTelegramPrimaryMedia,
|
||||
resolveTelegramRichMessagePlaceholder,
|
||||
} from "./bot/body-helpers.js";
|
||||
import {
|
||||
buildSenderName,
|
||||
extractTelegramLocation,
|
||||
@@ -151,7 +154,9 @@ function resolveMessageBody(msg: Message): string | undefined {
|
||||
if (location) {
|
||||
return formatLocationText(location);
|
||||
}
|
||||
return resolveTelegramPrimaryMedia(msg)?.placeholder;
|
||||
return (
|
||||
resolveTelegramRichMessagePlaceholder(msg) ?? resolveTelegramPrimaryMedia(msg)?.placeholder
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMediaType(placeholder?: string): string | undefined {
|
||||
|
||||
@@ -505,11 +505,12 @@ describe("telegramOutbound", () => {
|
||||
cfg: {} as never,
|
||||
to: "12345",
|
||||
text: "hello",
|
||||
formatting: { parseMode: "HTML" },
|
||||
formatting: { parseMode: "HTML", tableMode: "bullets" },
|
||||
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" });
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeMessagePresentation,
|
||||
renderMessagePresentationFallbackText,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
@@ -20,12 +21,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 = TELEGRAM_RICH_TEXT_LIMIT;
|
||||
export const TELEGRAM_TEXT_CHUNK_LIMIT = 4000;
|
||||
export const TELEGRAM_POLL_OPTION_LIMIT = 10;
|
||||
|
||||
type TelegramSendFn = typeof import("./send.js").sendMessageTelegram;
|
||||
@@ -53,12 +54,9 @@ function chunkTelegramOutboundText(
|
||||
limit: number,
|
||||
ctx?: { formatting?: OutboundDeliveryFormattingOptions },
|
||||
): string[] {
|
||||
return splitTelegramRichTextChunks({
|
||||
text,
|
||||
textLimit: limit,
|
||||
textMode: ctx?.formatting?.parseMode === "HTML" ? "html" : "markdown",
|
||||
chunkMode: ctx?.formatting?.chunkMode ?? "length",
|
||||
});
|
||||
return ctx?.formatting?.parseMode === "HTML"
|
||||
? splitTelegramHtmlChunks(text, limit)
|
||||
: chunkMarkdownTextWithMode(text, limit, ctx?.formatting?.chunkMode ?? "length");
|
||||
}
|
||||
|
||||
async function resolveTelegramSendContext(params: {
|
||||
@@ -77,6 +75,7 @@ async function resolveTelegramSendContext(params: {
|
||||
cfg: NonNullable<TelegramSendOpts>["cfg"];
|
||||
verbose: false;
|
||||
textMode?: "html";
|
||||
tableMode?: OutboundDeliveryFormattingOptions["tableMode"];
|
||||
messageThreadId?: number;
|
||||
replyToMessageId?: number;
|
||||
accountId?: string;
|
||||
@@ -96,6 +95,7 @@ async function resolveTelegramSendContext(params: {
|
||||
silent: params.silent,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
...(params.formatting?.parseMode === "HTML" ? { textMode: "html" as const } : {}),
|
||||
tableMode: params.formatting?.tableMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -251,9 +251,7 @@ export function createTelegramOutboundAdapter(
|
||||
});
|
||||
},
|
||||
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
|
||||
typeof fallbackLimit === "number"
|
||||
? Math.min(fallbackLimit, TELEGRAM_RICH_TEXT_LIMIT)
|
||||
: TELEGRAM_RICH_TEXT_LIMIT,
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
pollMaxOptions: TELEGRAM_POLL_OPTION_LIMIT,
|
||||
supportsPollDurationSeconds: true,
|
||||
supportsAnonymousPolls: true,
|
||||
|
||||
@@ -11,6 +11,8 @@ 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,
|
||||
@@ -25,6 +27,8 @@ 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 =
|
||||
| {
|
||||
@@ -49,7 +53,7 @@ export type TelegramRichTextMode = "markdown" | "html";
|
||||
|
||||
export type TelegramRichTextChunk = {
|
||||
text: string;
|
||||
textMode: TelegramRichTextMode;
|
||||
textMode: "html";
|
||||
plainText: string;
|
||||
};
|
||||
|
||||
@@ -166,7 +170,7 @@ export function buildTelegramRichHtml(
|
||||
html: string,
|
||||
options?: TelegramRichMessageOptions,
|
||||
): TelegramInputRichMessage {
|
||||
const safeHtml = sanitizeTelegramRichHtml(html);
|
||||
const safeHtml = prepareTelegramRichHtml(html);
|
||||
return options?.skipEntityDetection === true
|
||||
? { html: safeHtml, skip_entity_detection: true }
|
||||
: { html: safeHtml };
|
||||
@@ -182,6 +186,59 @@ 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;
|
||||
@@ -352,7 +409,11 @@ export function splitTelegramRichTextChunks(params: {
|
||||
chunkMode: ChunkMode;
|
||||
}): string[] {
|
||||
return params.textMode === "html"
|
||||
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
|
||||
? splitTelegramHtmlChunks(
|
||||
prepareTelegramRichHtml(params.text),
|
||||
params.textLimit,
|
||||
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
|
||||
)
|
||||
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode);
|
||||
}
|
||||
|
||||
@@ -365,15 +426,26 @@ export function splitTelegramRichMessageTextChunks(params: {
|
||||
skipEntityDetection?: boolean;
|
||||
}): TelegramRichTextChunk[] {
|
||||
const renderMarkdownChunk = (chunk: string) =>
|
||||
markdownToTelegramRichHtml(chunk, {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
});
|
||||
prepareTelegramRichHtml(
|
||||
markdownToTelegramRichHtml(chunk, {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
}),
|
||||
);
|
||||
const htmlChunks =
|
||||
params.textMode === "html"
|
||||
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
|
||||
? splitPreparedTelegramRichHtml({
|
||||
html: prepareTelegramRichHtml(params.text),
|
||||
sourceFallback: params.text,
|
||||
textLimit: params.textLimit,
|
||||
})
|
||||
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).flatMap(
|
||||
(chunk) => splitTelegramHtmlChunks(renderMarkdownChunk(chunk), params.textLimit),
|
||||
(chunk) =>
|
||||
splitPreparedTelegramRichHtml({
|
||||
html: renderMarkdownChunk(chunk),
|
||||
sourceFallback: chunk,
|
||||
textLimit: params.textLimit,
|
||||
}),
|
||||
);
|
||||
return htmlChunks.map((chunk) => ({
|
||||
text: chunk,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -30,8 +31,6 @@ import { buildInlineKeyboard } from "./inline-keyboard.js";
|
||||
import {
|
||||
isRecoverableTelegramNetworkError,
|
||||
isSafeToRetrySendError,
|
||||
isTelegramMessageHasNoTextError,
|
||||
isTelegramMessageNotModifiedError,
|
||||
isTelegramRateLimitError,
|
||||
isTelegramServerError,
|
||||
} from "./network-errors.js";
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
TELEGRAM_RICH_TEXT_LIMIT,
|
||||
toTelegramRichMessageContextParams,
|
||||
type TelegramEditRichMessageTextParams,
|
||||
type TelegramRichMessageContextParams,
|
||||
type TelegramRichTextChunk,
|
||||
} from "./rich-message.js";
|
||||
import {
|
||||
@@ -77,7 +77,9 @@ 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 = {
|
||||
@@ -100,6 +102,7 @@ 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. */
|
||||
@@ -176,6 +179,42 @@ 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",
|
||||
@@ -203,10 +242,12 @@ 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>();
|
||||
@@ -393,6 +434,14 @@ 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));
|
||||
}
|
||||
@@ -577,14 +626,13 @@ export async function sendMessageTelegram(
|
||||
const mediaMaxBytes =
|
||||
opts.maxBytes ??
|
||||
(typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024;
|
||||
const resolvedChatType = parseTelegramTarget(chatId).chatType;
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons, { chatType: resolvedChatType });
|
||||
const replyMarkup = buildInlineKeyboard(opts.buttons);
|
||||
|
||||
const threadParams = buildTelegramThreadReplyParams({
|
||||
thread: resolveTelegramSendThreadSpec({
|
||||
targetMessageThreadId: target.messageThreadId,
|
||||
messageThreadId: opts.messageThreadId,
|
||||
chatType: resolvedChatType,
|
||||
chatType: target.chatType,
|
||||
}),
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
replyQuoteText: opts.quoteText,
|
||||
@@ -606,125 +654,98 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
supportsBlockTables: true,
|
||||
});
|
||||
const richMessageOptions = {
|
||||
skipEntityDetection: account.config.linkPreview === false,
|
||||
tableMode,
|
||||
};
|
||||
const useRichMessages = account.config.richMessages === true;
|
||||
const tableMode =
|
||||
opts.tableMode ??
|
||||
resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: account.accountId,
|
||||
supportsBlockTables: useRichMessages,
|
||||
});
|
||||
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
|
||||
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);
|
||||
// Resolve link preview setting from config (default: enabled).
|
||||
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
|
||||
type TelegramTextParams = Record<string, unknown> & {
|
||||
message_thread_id?: number;
|
||||
reply_markup?: ReturnType<typeof buildInlineKeyboard>;
|
||||
type TelegramTextChunk = {
|
||||
plainText: string;
|
||||
htmlText?: string;
|
||||
};
|
||||
|
||||
const sendTelegramTextChunk = async (
|
||||
chunk: TelegramRichTextChunk,
|
||||
params?: TelegramTextParams,
|
||||
chunk: TelegramTextChunk,
|
||||
params?: TelegramSendMessageParams,
|
||||
) => {
|
||||
if (useRichTextSend) {
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richParams = {
|
||||
...params,
|
||||
...(opts.silent === true ? { disable_notification: true } : {}),
|
||||
};
|
||||
const result = await requestWithChatNotFound(
|
||||
() =>
|
||||
richRawApi.sendRichMessage({
|
||||
chat_id: chatId,
|
||||
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
|
||||
...richParams,
|
||||
}),
|
||||
"richMessage",
|
||||
);
|
||||
return { result, acceptedParams: params, operation: "sendRichMessage" };
|
||||
const baseParams = params ? { ...params } : {};
|
||||
if (linkPreviewOptions) {
|
||||
baseParams.link_preview_options = linkPreviewOptions;
|
||||
}
|
||||
|
||||
const legacyParams: Record<string, unknown> = {
|
||||
parse_mode: "HTML",
|
||||
...threadParams,
|
||||
const plainParams: TelegramSendMessageParams = {
|
||||
...baseParams,
|
||||
...(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 hasPlainParams = Object.keys(plainParams).length > 0;
|
||||
const requestPlain = (label: string) =>
|
||||
requestWithChatNotFound(
|
||||
() =>
|
||||
hasPlainParams
|
||||
? api.sendMessage(chatId, chunk.plainText, plainParams)
|
||||
: api.sendMessage(chatId, chunk.plainText),
|
||||
label,
|
||||
);
|
||||
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 };
|
||||
};
|
||||
|
||||
const buildTextParams = (isLastChunk: boolean) =>
|
||||
useRichTextSend
|
||||
? hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined
|
||||
: isLastChunk && replyMarkup
|
||||
? { reply_markup: replyMarkup }
|
||||
: undefined;
|
||||
hasThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...threadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const buildRichTextParams = (isLastChunk: boolean) =>
|
||||
hasRichThreadParams || (isLastChunk && replyMarkup)
|
||||
? {
|
||||
...richThreadParams,
|
||||
...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const sendTelegramTextChunks = async (
|
||||
chunks: TelegramRichTextChunk[],
|
||||
chunks: TelegramTextChunk[],
|
||||
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,
|
||||
operation,
|
||||
} = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1));
|
||||
const { result: res, acceptedParams } = await sendTelegramTextChunk(
|
||||
chunk,
|
||||
buildTextParams(index === chunks.length - 1),
|
||||
);
|
||||
const messageId = resolveTelegramMessageIdOrThrow(res, context);
|
||||
recordSentMessage(chatId, messageId, cfg);
|
||||
await recordOutboundMessageForPromptContext({
|
||||
@@ -741,7 +762,6 @@ export async function sendMessageTelegram(
|
||||
lastMessageId = String(messageId);
|
||||
lastChatId = String(res?.chat?.id ?? chatId);
|
||||
lastAcceptedParams = acceptedParams;
|
||||
lastOperation = operation;
|
||||
sentChunkCount += 1;
|
||||
}
|
||||
if (lastMessageId) {
|
||||
@@ -749,7 +769,7 @@ export async function sendMessageTelegram(
|
||||
accountId: account.accountId,
|
||||
chatId: lastChatId,
|
||||
messageId: lastMessageId,
|
||||
operation: lastOperation,
|
||||
operation: "sendMessage",
|
||||
deliveryKind: "text",
|
||||
messageThreadId: lastAcceptedParams?.message_thread_id,
|
||||
replyToMessageId: opts.replyToMessageId,
|
||||
@@ -760,26 +780,117 @@ export async function sendMessageTelegram(
|
||||
return { messageId: lastMessageId, chatId: lastChatId };
|
||||
};
|
||||
|
||||
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
|
||||
if (!useRichTextSend) {
|
||||
return splitTelegramHtmlChunks(renderHtmlText(rawText), textLimit).map((chunkText) => ({
|
||||
text: chunkText,
|
||||
textMode: "html",
|
||||
plainText: telegramHtmlToPlainTextFallback(chunkText),
|
||||
}));
|
||||
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 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,
|
||||
chunkMode: resolveChunkMode(cfg, "telegram", account.accountId),
|
||||
tableMode,
|
||||
skipEntityDetection: richMessageOptions.skipEntityDetection,
|
||||
skipEntityDetection: account.config.linkPreview === false,
|
||||
});
|
||||
};
|
||||
|
||||
const sendChunkedText = async (rawText: string, context: string) =>
|
||||
await sendTelegramTextChunks(buildChunkedTextPlan(rawText), context);
|
||||
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 };
|
||||
};
|
||||
|
||||
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
|
||||
try {
|
||||
@@ -1357,16 +1468,13 @@ 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, { chatType: resolvedChatType }) ?? {
|
||||
inline_keyboard: [],
|
||||
};
|
||||
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
|
||||
try {
|
||||
await requestWithDiag(
|
||||
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
|
||||
@@ -1404,7 +1512,6 @@ export async function editMessageTelegram(
|
||||
gatewayClientScopes: opts.gatewayClientScopes,
|
||||
});
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const resolvedChatType = parseTelegramTarget(chatId).chatType;
|
||||
const requestWithDiag = createTelegramRequestWithDiag({
|
||||
cfg,
|
||||
account,
|
||||
@@ -1421,34 +1528,47 @@ 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: true,
|
||||
supportsBlockTables: useRichMessages,
|
||||
});
|
||||
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
|
||||
const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text;
|
||||
const richRawApi = getTelegramRichRawApi(api);
|
||||
const richMessage = buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode,
|
||||
});
|
||||
const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined;
|
||||
const richMessage = useRichMessages
|
||||
? buildTelegramRichMessage(text, textMode, {
|
||||
skipEntityDetection: opts.linkPreview === false,
|
||||
tableMode,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// 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, { chatType: resolvedChatType })
|
||||
: undefined;
|
||||
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
|
||||
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
|
||||
|
||||
const textEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> = {};
|
||||
const textEditParams: TelegramEditMessageTextParams = {
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
if (opts.linkPreview === false) {
|
||||
textEditParams.link_preview_options = { is_disabled: true };
|
||||
}
|
||||
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",
|
||||
@@ -1463,18 +1583,42 @@ export async function editMessageTelegram(
|
||||
plainCaptionParams.reply_markup = replyMarkup;
|
||||
}
|
||||
|
||||
const performTextEdit = () =>
|
||||
requestWithEditShouldLog(
|
||||
() =>
|
||||
richRawApi.editMessageText({
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
rich_message: richMessage,
|
||||
...textEditParams,
|
||||
}),
|
||||
"editMessage",
|
||||
(err) => !isTelegramMessageNotModifiedError(err),
|
||||
);
|
||||
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 performCaptionEdit = () =>
|
||||
withTelegramHtmlParseFallback({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Telegram tests cover telegram outbound plugin behavior.
|
||||
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
|
||||
// Telegram tests cover telegram outbound plugin behavior.
|
||||
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, 32_768, "length");
|
||||
const expected = chunkMarkdownTextWithMode(text, 4000, "length");
|
||||
|
||||
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual(expected);
|
||||
expect(telegramOutbound.deliveryMode).toBe("direct");
|
||||
expect(telegramOutbound.chunkerMode).toBe("markdown");
|
||||
expect(telegramOutbound.chunkedTextFormatting).toBeUndefined();
|
||||
expect(telegramOutbound.textChunkLimit).toBe(32_768);
|
||||
expect(telegramOutbound.textChunkLimit).toBe(4000);
|
||||
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
|
||||
"markdown",
|
||||
);
|
||||
@@ -43,7 +43,7 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]);
|
||||
});
|
||||
|
||||
it("keeps markdown tables intact for rich message parsing", () => {
|
||||
it("preserves markdown tables for the configured delivery renderer", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = ["| Name | Value |", "|------|-------|", "| A | 1 |"].join("\n");
|
||||
|
||||
@@ -54,63 +54,66 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("keeps wide markdown tables for rich HTML rendering", () => {
|
||||
it("keeps wide markdown tables as visible text in the HTML text path", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = markdownTable(21);
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks?.[0]).toContain("| H21 |");
|
||||
expect(chunks?.[0]).toContain("| 1 | 2 | 3 |");
|
||||
});
|
||||
|
||||
it("keeps fenced and unfenced wide markdown tables for rich HTML rendering", () => {
|
||||
it("preserves both fenced and unfenced wide tables as visible text", () => {
|
||||
clearTelegramRuntime();
|
||||
const fencedTable = markdownTable(25);
|
||||
const outsideTable = markdownTable(21);
|
||||
const text = ["Before", "~~~", fencedTable, "~~~", "After", outsideTable].join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks?.[0]).toContain("Before");
|
||||
expect(chunks?.[0]).toContain("After");
|
||||
expect(chunks?.[0]).toContain(fencedTable);
|
||||
expect(chunks?.[0]).toContain(outsideTable);
|
||||
});
|
||||
|
||||
it("chunks rich markdown by Telegram's block limit", () => {
|
||||
it("chunks long markdown paragraphs by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 900 }, (_, index) => `Paragraph ${index + 1}`).join("\n\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
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);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Paragraph 900");
|
||||
});
|
||||
|
||||
it("chunks rich markdown headings by Telegram's block limit", () => {
|
||||
it("chunks long markdown headings by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 600 }, (_, index) => `# Heading ${index + 1}`).join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
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);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Heading 600");
|
||||
});
|
||||
|
||||
it("keeps long rich markdown lists intact", () => {
|
||||
it("chunks long markdown lists by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = Array.from({ length: 600 }, (_, index) => `- Item ${index + 1}`).join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Item 600");
|
||||
});
|
||||
|
||||
it("keeps tall rich markdown tables intact", () => {
|
||||
it("chunks tall markdown tables by the Telegram text-message limit", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = [
|
||||
"| Name | Value |",
|
||||
@@ -118,8 +121,10 @@ describe("telegramPlugin outbound", () => {
|
||||
...Array.from({ length: 600 }, (_, index) => `| Row ${index + 1} | ${index + 1} |`),
|
||||
].join("\n");
|
||||
|
||||
const chunks = telegramOutbound.chunker?.(text, 32_768);
|
||||
const chunks = telegramOutbound.chunker?.(text, 4000);
|
||||
|
||||
expect(chunks).toEqual([text]);
|
||||
expect((chunks?.length ?? 0) > 1).toBe(true);
|
||||
expect(chunks?.every((chunk) => chunk.length <= 4000)).toBe(true);
|
||||
expect(chunks?.join("")).toContain("Row 600");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,16 +127,6 @@ 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();
|
||||
|
||||
@@ -417,6 +407,7 @@ 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");
|
||||
});
|
||||
|
||||
@@ -434,15 +425,14 @@ describe("web auto-reply connection", () => {
|
||||
error: "Stream Errored (logged out)",
|
||||
},
|
||||
] as const)(
|
||||
"clears stale auth and active listener after terminal status $status",
|
||||
"stops active listener and preserves auth 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(
|
||||
resolveWebCredsPath(authDir),
|
||||
JSON.stringify({ me: { id: "123@s.whatsapp.net" } }),
|
||||
);
|
||||
await fs.writeFile(credsPath, credsJson);
|
||||
setLoadConfigMock({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -489,7 +479,7 @@ describe("web auto-reply connection", () => {
|
||||
expect(scripted.getListenerCount()).toBe(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
expect(getActiveWebListener(accountId)).toBeNull();
|
||||
await expectPathMissing(authDir);
|
||||
await expect(fs.readFile(credsPath, "utf8")).resolves.toBe(credsJson);
|
||||
expect(
|
||||
statuses.filter((entry) => entry.connected === false && entry.healthState === healthState),
|
||||
).not.toEqual([]);
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
resolveReconnectPolicy,
|
||||
sleepWithAbort,
|
||||
} from "../reconnect.js";
|
||||
import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js";
|
||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
|
||||
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
|
||||
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
|
||||
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
|
||||
@@ -142,43 +142,6 @@ 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(
|
||||
@@ -431,26 +394,12 @@ 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 relink with \`${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 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.`,
|
||||
);
|
||||
} else {
|
||||
runtime.error(
|
||||
@@ -668,24 +617,10 @@ 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,
|
||||
@@ -695,7 +630,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 relink with \`${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 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.`,
|
||||
);
|
||||
} else {
|
||||
reconnectLogger.warn(
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "./connection-controller.js";
|
||||
import { enqueueCredsSave, writeCredsJsonAtomically } from "./creds-persistence.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
|
||||
import { createWaSocket, readWebAuthExistsForDecision, waitForWaConnection } from "./session.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
logoutWeb,
|
||||
readWebAuthExistsForDecision,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
|
||||
|
||||
vi.mock("./session.js", async () => {
|
||||
@@ -22,12 +27,14 @@ 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") {
|
||||
@@ -48,11 +55,78 @@ 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 });
|
||||
@@ -138,8 +212,7 @@ describe("WhatsAppConnectionController", () => {
|
||||
});
|
||||
|
||||
it("restarts login once on status 408 and preserves replacement socket options", async () => {
|
||||
const initialSock = createSocketWithTransportEmitter();
|
||||
const replacementSock = createSocketWithTransportEmitter();
|
||||
const harness = createLoginResultHarness();
|
||||
const waitForConnection = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
|
||||
@@ -149,18 +222,14 @@ describe("WhatsAppConnectionController", () => {
|
||||
const createSocket = vi.fn(
|
||||
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
|
||||
opts?.onQr?.("qr-after-timeout");
|
||||
return replacementSock;
|
||||
return harness.replacementSock;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await waitForWhatsAppLoginResult({
|
||||
sock: initialSock as never,
|
||||
authDir: "/tmp/wa-auth",
|
||||
isLegacyAuthDir: false,
|
||||
const result = await harness.run({
|
||||
verbose: true,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
waitForConnection: waitForConnection as never,
|
||||
createSocket: createSocket as never,
|
||||
waitForConnection,
|
||||
createSocket,
|
||||
socketTiming: {
|
||||
connectTimeoutMs: 10_000,
|
||||
defaultQueryTimeoutMs: 20_000,
|
||||
@@ -173,24 +242,28 @@ describe("WhatsAppConnectionController", () => {
|
||||
expect(result).toEqual({
|
||||
outcome: "connected",
|
||||
restarted: true,
|
||||
sock: replacementSock,
|
||||
sock: harness.replacementSock,
|
||||
});
|
||||
expect(initialSock.end).toHaveBeenCalledOnce();
|
||||
expect(harness.initialSock.end).toHaveBeenCalledOnce();
|
||||
expect(createSocket).toHaveBeenCalledWith(false, true, {
|
||||
authDir: "/tmp/wa-auth",
|
||||
authDir: loginAuthDir,
|
||||
connectTimeoutMs: 10_000,
|
||||
defaultQueryTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 30_000,
|
||||
onQr,
|
||||
});
|
||||
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
|
||||
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(2, replacementSock, { timeout: "none" });
|
||||
expect(onSocketReplaced).toHaveBeenCalledWith(harness.replacementSock);
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(1, harness.initialSock, {
|
||||
timeout: "none",
|
||||
});
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(2, harness.replacementSock, {
|
||||
timeout: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
|
||||
const initialSock = createSocketWithTransportEmitter();
|
||||
const harness = createLoginResultHarness();
|
||||
const afterTimeoutSock = createSocketWithTransportEmitter();
|
||||
const afterPairingRestartSock = createSocketWithTransportEmitter();
|
||||
const waitForConnection = vi
|
||||
@@ -203,15 +276,7 @@ describe("WhatsAppConnectionController", () => {
|
||||
.mockResolvedValueOnce(afterTimeoutSock)
|
||||
.mockResolvedValueOnce(afterPairingRestartSock);
|
||||
|
||||
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,
|
||||
});
|
||||
const result = await harness.run({ waitForConnection, createSocket });
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "connected",
|
||||
@@ -220,34 +285,131 @@ describe("WhatsAppConnectionController", () => {
|
||||
});
|
||||
expect(createSocket).toHaveBeenCalledTimes(2);
|
||||
expect(waitForConnection).toHaveBeenCalledTimes(3);
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(1, initialSock, { timeout: "none" });
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(1, harness.initialSock, {
|
||||
timeout: "none",
|
||||
});
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(2, afterTimeoutSock, { timeout: "none" });
|
||||
expect(waitForConnection).toHaveBeenNthCalledWith(3, afterPairingRestartSock, {
|
||||
timeout: "none",
|
||||
});
|
||||
expect(initialSock.end).toHaveBeenCalledOnce();
|
||||
expect(harness.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 initialSock = createSocketWithTransportEmitter();
|
||||
const replacementSock = createSocketWithTransportEmitter();
|
||||
const harness = createLoginResultHarness();
|
||||
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
|
||||
const waitForConnection = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockRejectedValueOnce(timeoutError);
|
||||
const createSocket = vi.fn(async () => replacementSock);
|
||||
const createSocket = vi.fn(async () => harness.replacementSock);
|
||||
|
||||
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,
|
||||
});
|
||||
const result = await harness.run({ waitForConnection, createSocket });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: "failed",
|
||||
|
||||
@@ -36,6 +36,8 @@ 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";
|
||||
@@ -236,6 +238,31 @@ 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 {
|
||||
@@ -258,7 +285,7 @@ export async function waitForWhatsAppLoginResult(params: {
|
||||
}
|
||||
return {
|
||||
outcome: "connected",
|
||||
restarted: postPairingRestarted || timeoutRestarted,
|
||||
restarted: postPairingRestarted || timeoutRestarted || loggedOutRestarted,
|
||||
sock: currentSock,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -274,37 +301,51 @@ export async function waitForWhatsAppLoginResult(params: {
|
||||
timeoutRestarted = true;
|
||||
}
|
||||
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
|
||||
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,
|
||||
};
|
||||
const replacementFailure = await replaceLoginSocket();
|
||||
if (replacementFailure) {
|
||||
return replacementFailure;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (statusCode === LOGGED_OUT_STATUS) {
|
||||
await logoutWeb({
|
||||
if (loggedOutRestarted) {
|
||||
return {
|
||||
outcome: "logged-out",
|
||||
message: WHATSAPP_LOGGED_OUT_RELINK_MESSAGE,
|
||||
statusCode: LOGGED_OUT_STATUS,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
closeWaSocket(currentSock);
|
||||
const cleared = await logoutWeb({
|
||||
authDir: params.authDir,
|
||||
isLegacyAuthDir: params.isLegacyAuthDir,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
return {
|
||||
outcome: "logged-out",
|
||||
message: WHATSAPP_LOGGED_OUT_RELINK_MESSAGE,
|
||||
statusCode: LOGGED_OUT_STATUS,
|
||||
error: err,
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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 {
|
||||
@@ -41,17 +42,82 @@ 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();
|
||||
@@ -98,6 +164,7 @@ 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
|
||||
@@ -122,7 +189,7 @@ describe("login-qr", () => {
|
||||
timeoutMs: 5000,
|
||||
accountId: rotatingAccountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const resultPromise = waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
@@ -142,62 +209,195 @@ describe("login-qr", () => {
|
||||
|
||||
it("returns a replacement QR when status 408 happens before the first QR", async () => {
|
||||
const accountId = "timeout-before-first-qr";
|
||||
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;
|
||||
},
|
||||
);
|
||||
queueSilentSocket();
|
||||
queueQrSocket("qr-after-timeout");
|
||||
waitForWaConnectionMock
|
||||
.mockRejectedValueOnce({ output: { statusCode: 408 } })
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
.mockImplementation(waitForever);
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
|
||||
expect(start).toEqual({
|
||||
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
|
||||
message: "Scan this QR in WhatsApp → Linked Devices.",
|
||||
});
|
||||
expectScanQrResult(start, "qr-after-timeout");
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
|
||||
waitForWaConnectionMock.mockRejectedValueOnce({
|
||||
output: { statusCode: 401 },
|
||||
});
|
||||
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);
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const result = await waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
currentQrDataUrl: start.qrDataUrl,
|
||||
accountId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
connected: false,
|
||||
message:
|
||||
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.",
|
||||
message: refreshedQrMessage,
|
||||
qrDataUrl: encodedQr("qr-after-logout"),
|
||||
});
|
||||
expect(logoutWebMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("caps oversized wait timeouts to a timer-safe delay", async () => {
|
||||
const accountId = "oversized-wait-timeout";
|
||||
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
|
||||
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("data:image/png;base64,encoded:qr-data");
|
||||
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);
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000, accountId });
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
vi.useFakeTimers();
|
||||
const resultPromise = waitForWebLogin({
|
||||
@@ -220,7 +420,7 @@ describe("login-qr", () => {
|
||||
logoutWebMock.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const result = await waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
@@ -273,7 +473,7 @@ describe("login-qr", () => {
|
||||
expect(result.message).toMatch(/retry/i);
|
||||
});
|
||||
|
||||
it("reports a recovered linked session when socket bootstrap restores auth without a QR", async () => {
|
||||
it("reports a recovered linked session when saved auth has no active listener", async () => {
|
||||
createWaSocketMock.mockImplementationOnce(
|
||||
async (
|
||||
_printQr: boolean,
|
||||
@@ -286,9 +486,7 @@ describe("login-qr", () => {
|
||||
);
|
||||
waitForWaConnectionMock.mockResolvedValueOnce(undefined);
|
||||
readWebSelfIdMock.mockReturnValueOnce({ e164: "+5511977000000", jid: null, lid: null });
|
||||
readWebAuthExistsForDecisionMock
|
||||
.mockResolvedValueOnce({ outcome: "stable", exists: false })
|
||||
.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
readWebAuthExistsForDecisionMock.mockResolvedValue({ outcome: "stable", exists: true });
|
||||
|
||||
const result = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
|
||||
@@ -304,22 +502,11 @@ describe("login-qr", () => {
|
||||
});
|
||||
|
||||
it("surfaces the latest QR after the socket rotates it", async () => {
|
||||
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(() => {}));
|
||||
queueRotatingQrSocket("qr-data", "qr-data-2", 100);
|
||||
waitForWaConnectionMock.mockImplementation(waitForever);
|
||||
|
||||
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const resultPromise = waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
@@ -329,11 +516,7 @@ describe("login-qr", () => {
|
||||
await waitMs(140);
|
||||
await flushTasks();
|
||||
|
||||
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",
|
||||
});
|
||||
expectQrRefreshResult(await resultPromise, "qr-data-2");
|
||||
});
|
||||
|
||||
it("does not short-circuit on an existing QR when the waiter has no current QR image", async () => {
|
||||
@@ -352,7 +535,7 @@ describe("login-qr", () => {
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
await expect(
|
||||
waitForWebLogin({
|
||||
@@ -370,18 +553,7 @@ describe("login-qr", () => {
|
||||
let resolveLogin: () => void = () => {
|
||||
throw new Error("Expected login wait to be pending");
|
||||
};
|
||||
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;
|
||||
},
|
||||
);
|
||||
queueRotatingQrSocket("qr-data", "qr-data-2", 20);
|
||||
waitForWaConnectionMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -396,7 +568,7 @@ describe("login-qr", () => {
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
await waitMs(50);
|
||||
await flushTasks();
|
||||
@@ -427,13 +599,13 @@ describe("login-qr", () => {
|
||||
resolveFirstConnection = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
.mockImplementation(waitForever);
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const waiter = waitForWebLogin({
|
||||
timeoutMs: 1000,
|
||||
@@ -449,7 +621,7 @@ describe("login-qr", () => {
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(replacement.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(replacement.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
resolveFirstConnection();
|
||||
|
||||
@@ -479,7 +651,7 @@ describe("login-qr", () => {
|
||||
return sock as never;
|
||||
},
|
||||
);
|
||||
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
|
||||
waitForWaConnectionMock.mockImplementation(waitForever);
|
||||
renderQrPngDataUrlMock.mockImplementation((qr) =>
|
||||
qr === "qr-data-2"
|
||||
? new Promise<string>(() => {})
|
||||
@@ -490,7 +662,7 @@ describe("login-qr", () => {
|
||||
timeoutMs: 5000,
|
||||
accountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
onQr("qr-data-2");
|
||||
await flushTasks();
|
||||
@@ -502,7 +674,7 @@ describe("login-qr", () => {
|
||||
|
||||
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
|
||||
expect(reused).toEqual({
|
||||
qrDataUrl: "data:image/png;base64,encoded:qr-data",
|
||||
qrDataUrl: encodedQr("qr-data"),
|
||||
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
|
||||
});
|
||||
});
|
||||
@@ -518,7 +690,7 @@ describe("login-qr", () => {
|
||||
resolveRender = resolve;
|
||||
}),
|
||||
);
|
||||
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
|
||||
waitForWaConnectionMock.mockImplementation(waitForever);
|
||||
|
||||
const resultPromise = startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
@@ -528,34 +700,20 @@ describe("login-qr", () => {
|
||||
|
||||
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
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.",
|
||||
});
|
||||
resolveRender(encodedQr("qr-data"));
|
||||
expectScanQrResult(await resultPromise);
|
||||
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the same rotated QR to concurrent waiters that share the same current image", async () => {
|
||||
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(() => {}));
|
||||
queueRotatingQrSocket("qr-data", "qr-data-2", 100);
|
||||
waitForWaConnectionMock.mockImplementation(waitForever);
|
||||
|
||||
const start = await startWebLoginWithQr({
|
||||
timeoutMs: 5000,
|
||||
accountId: concurrentAccountId,
|
||||
});
|
||||
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
|
||||
expect(start.qrDataUrl).toBe(encodedQr("qr-data"));
|
||||
|
||||
const waiterA = waitForWebLogin({
|
||||
timeoutMs: 5000,
|
||||
@@ -572,15 +730,7 @@ describe("login-qr", () => {
|
||||
await waitMs(140);
|
||||
await flushTasks();
|
||||
|
||||
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",
|
||||
});
|
||||
expectQrRefreshResult(await waiterA, "qr-data-2");
|
||||
expectQrRefreshResult(await waiterB, "qr-data-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
import { renderQrPngDataUrl } from "./qr-image.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
logoutWeb,
|
||||
readWebAuthExistsForDecision,
|
||||
readWebSelfId,
|
||||
WHATSAPP_AUTH_UNSTABLE_CODE,
|
||||
@@ -327,13 +330,32 @@ export async function startWebLoginWithQr(
|
||||
message: "WhatsApp auth state is still stabilizing. Retry login in a moment.",
|
||||
};
|
||||
}
|
||||
if (authState.exists && !opts.force) {
|
||||
if (authState.exists && !opts.force && getActiveWebListener(account.accountId)) {
|
||||
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) {
|
||||
|
||||
@@ -168,18 +168,21 @@ describe("loginWeb coverage", () => {
|
||||
expect(renderQrTerminalMock).toHaveBeenCalledWith("restart-qr", { small: true });
|
||||
});
|
||||
|
||||
it("clears creds and throws when logged out", async () => {
|
||||
waitForWaConnectionMock.mockRejectedValueOnce({
|
||||
output: { statusCode: 401 },
|
||||
});
|
||||
it("clears stale creds and continues login when logged out", async () => {
|
||||
waitForWaConnectionMock
|
||||
.mockRejectedValueOnce({
|
||||
output: { statusCode: 401 },
|
||||
})
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
await expect(loginWeb(false, waitForWaConnectionMock as never, runtime)).rejects.toThrow(
|
||||
/cache cleared/i,
|
||||
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.",
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -8,6 +8,9 @@ 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";
|
||||
|
||||
@@ -197,25 +200,26 @@ 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}`;
|
||||
return `${WHATSAPP_FENCE_PLACEHOLDER}${fences.length - 1}${WHATSAPP_PLACEHOLDER_TERMINATOR}`;
|
||||
});
|
||||
|
||||
const inlineCodes: string[] = [];
|
||||
result = result.replace(/`[^`\n]+`/g, (match) => {
|
||||
inlineCodes.push(match);
|
||||
return `${WHATSAPP_INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}`;
|
||||
return `${WHATSAPP_INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}${WHATSAPP_PLACEHOLDER_TERMINATOR}`;
|
||||
});
|
||||
|
||||
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+)`, "g"),
|
||||
new RegExp(`${escapeRegExp(WHATSAPP_INLINE_CODE_PLACEHOLDER)}(\\d+)${terminator}`, "g"),
|
||||
(_, idx) => inlineCodes[Number(idx)] ?? "",
|
||||
);
|
||||
result = result.replace(
|
||||
new RegExp(`${escapeRegExp(WHATSAPP_FENCE_PLACEHOLDER)}(\\d+)`, "g"),
|
||||
new RegExp(`${escapeRegExp(WHATSAPP_FENCE_PLACEHOLDER)}(\\d+)${terminator}`, "g"),
|
||||
(_, idx) => fences[Number(idx)] ?? "",
|
||||
);
|
||||
return result;
|
||||
|
||||
@@ -41,6 +41,12 @@ 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);
|
||||
});
|
||||
@@ -50,6 +56,11 @@ 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(
|
||||
|
||||
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
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,
|
||||
@@ -361,15 +362,6 @@ 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);
|
||||
@@ -385,19 +377,40 @@ 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): DatabaseSync {
|
||||
function createDatabase(dbPath: string): {
|
||||
db: DatabaseSync;
|
||||
maintenance: ReturnType<typeof configureSqliteConnectionPragmas>;
|
||||
} {
|
||||
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);
|
||||
configureWorkboardDatabase(db);
|
||||
ensureWorkboardSchema(db);
|
||||
hardenWorkboardDatabaseFiles(dbPath);
|
||||
return db;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {
|
||||
@@ -1401,12 +1414,17 @@ export function createWorkboardSqliteStores(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
): WorkboardSqliteStores {
|
||||
const db = createDatabase(options.dbPath ?? resolveWorkboardSqlitePath(options.env));
|
||||
const { db, maintenance } = createDatabase(
|
||||
options.dbPath ?? resolveWorkboardSqlitePath(options.env),
|
||||
);
|
||||
return {
|
||||
cards: new WorkboardSqliteCardStore(db),
|
||||
boards: new WorkboardSqliteBoardStore(db),
|
||||
subscriptions: new WorkboardSqliteSubscriptionStore(db),
|
||||
attachments: new WorkboardSqliteAttachmentStore(db),
|
||||
close: () => db.close(),
|
||||
close: () => {
|
||||
maintenance.close();
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user